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,776 @@
|
|
|
1
|
+
"""Dymola-specific backend with batch execution."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import math
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ...config import Config
|
|
11
|
+
from ...discovery.library_state import dependency_file
|
|
12
|
+
from ...discovery.test_registry import TestModel
|
|
13
|
+
from ...storage.batch_manifest import BatchManifest
|
|
14
|
+
from .. import register
|
|
15
|
+
from ..base import Backend, _print_run_header
|
|
16
|
+
from ..common import result_files
|
|
17
|
+
from ..common.mat_reader import read_result_mat
|
|
18
|
+
from ..common.proc_kill import kill_process_tree
|
|
19
|
+
from ..common.result_variables import (
|
|
20
|
+
compute_needed_variables,
|
|
21
|
+
extract_variables,
|
|
22
|
+
fold_diagnostics_into_statistics,
|
|
23
|
+
unfinished_result,
|
|
24
|
+
)
|
|
25
|
+
from ..types import (
|
|
26
|
+
Capability,
|
|
27
|
+
DatasetType,
|
|
28
|
+
TestResult,
|
|
29
|
+
TestRunResult,
|
|
30
|
+
timeout_message,
|
|
31
|
+
)
|
|
32
|
+
from .log_parser import parse_test_logs
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class DymolaConfig:
|
|
39
|
+
"""Dymola-specific settings extracted from the universal Config."""
|
|
40
|
+
|
|
41
|
+
show_ide: bool = False
|
|
42
|
+
backend_setup: list[str] = field(default_factory=list)
|
|
43
|
+
diagnostic_variables: list[str] = field(
|
|
44
|
+
default_factory=lambda: ["CPUtime", "EventCounter"],
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def from_config(cls, config: Config) -> "DymolaConfig":
|
|
49
|
+
"""Extract Dymola-specific fields from the universal Config."""
|
|
50
|
+
return cls(
|
|
51
|
+
show_ide=config.show_ide,
|
|
52
|
+
backend_setup=list(config.backend_setup),
|
|
53
|
+
diagnostic_variables=list(config.diagnostic_variables),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@register("Dymola")
|
|
58
|
+
class DymolaBackend(Backend):
|
|
59
|
+
"""Runs Modelica simulations using Dymola with batch execution.
|
|
60
|
+
|
|
61
|
+
Instead of launching a separate Dymola process per test, tests are
|
|
62
|
+
grouped into batches. Each batch runs in a single Dymola session:
|
|
63
|
+
load libraries once, run N tests, exit. This dramatically reduces
|
|
64
|
+
startup overhead for large test suites.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
# NOTE: no Capability.FMU_EXPORT here. Dymola *can* export FMUs, but only
|
|
68
|
+
# through the DymolaInterface — see PersistentDymolaBackend, which declares
|
|
69
|
+
# the flag and implements export_fmu(). Batch mode would need a
|
|
70
|
+
# `translateModelFMU(...)` .mos script + `dymola -nowindow` spawn + FMU
|
|
71
|
+
# locator; nobody has needed it (persistent is the default path), so
|
|
72
|
+
# rather than declare a capability we don't have, we don't declare it and
|
|
73
|
+
# the cross-backend chain skips batch-mode Dymola with a clear message.
|
|
74
|
+
capabilities = frozenset(
|
|
75
|
+
{
|
|
76
|
+
Capability.PERSISTENT_WORKERS, # DymolaInterface-based workers (default)
|
|
77
|
+
Capability.BATCH_FALLBACK, # .mos script backend (--batch flag)
|
|
78
|
+
Capability.LIVE_RESULTS, # dymosim appends dsres.mat as it goes
|
|
79
|
+
}
|
|
80
|
+
)
|
|
81
|
+
produced_datasets = frozenset({DatasetType.TIME_SERIES})
|
|
82
|
+
artifact_files = (
|
|
83
|
+
("dslog.txt", "Simulation log"),
|
|
84
|
+
("translation_log.txt", "Translation log"),
|
|
85
|
+
("dsin.txt", "Simulation input"),
|
|
86
|
+
("dsfinal.txt", "Final values"),
|
|
87
|
+
("simulate.mos", "Simulation script"),
|
|
88
|
+
("dsres.mat", "Result file"),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def __init__(self, config: Config):
|
|
92
|
+
super().__init__(config)
|
|
93
|
+
self.dymola_config = DymolaConfig.from_config(config)
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def persistent_backend_cls(cls):
|
|
97
|
+
from .persistent_backend import PersistentDymolaBackend
|
|
98
|
+
|
|
99
|
+
return PersistentDymolaBackend
|
|
100
|
+
|
|
101
|
+
def run_tests(self, tests: list[TestModel]) -> list[BatchManifest]:
|
|
102
|
+
"""Run tests in batches with parallelism."""
|
|
103
|
+
if not tests:
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
setup = self._prepare_run(tests)
|
|
107
|
+
total = setup.total
|
|
108
|
+
manifest = setup.manifest
|
|
109
|
+
test_items = setup.keyed_tests
|
|
110
|
+
|
|
111
|
+
# Clean and recreate per-test directories ONLY for tests being run
|
|
112
|
+
# this batch (preserves prior dirs for tests not in this run).
|
|
113
|
+
# Note (D91): cmd_run.cli now centrally wipes in-scope test_dirs
|
|
114
|
+
# before backend.run_tests; this in-backend wipe is a defensive
|
|
115
|
+
# duplicate that protects against future callers of run_tests
|
|
116
|
+
# outside cmd_run (e.g., direct test harness calls). Cheap (rmtree
|
|
117
|
+
# on empty) and self-documenting at the point freshness matters.
|
|
118
|
+
import shutil
|
|
119
|
+
|
|
120
|
+
for test, test_key in test_items:
|
|
121
|
+
test_dir = self.config.work_dir / test_key
|
|
122
|
+
if test_dir.exists():
|
|
123
|
+
shutil.rmtree(test_dir)
|
|
124
|
+
test_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
_generate_test_mos(test, test_key, test_dir)
|
|
126
|
+
|
|
127
|
+
# Generate shared startup and shutdown scripts
|
|
128
|
+
startup_path = _generate_startup_mos(self.config, self.dymola_config)
|
|
129
|
+
shutdown_path = _generate_shutdown_mos(self.config)
|
|
130
|
+
|
|
131
|
+
# Split tests into batches for parallel workers.
|
|
132
|
+
# If batch_size is set, chunks are that size (many small batches, queue-dispatched
|
|
133
|
+
# to workers — better load balancing, smaller crash blast radius).
|
|
134
|
+
# Otherwise use one big batch per worker (original behavior, minimizes library reloads).
|
|
135
|
+
n_workers = max(1, self.config.parallel)
|
|
136
|
+
if self.config.batch_size and self.config.batch_size > 0:
|
|
137
|
+
batch_size = self.config.batch_size
|
|
138
|
+
else:
|
|
139
|
+
batch_size = math.ceil(total / n_workers)
|
|
140
|
+
batches = []
|
|
141
|
+
for i in range(0, total, batch_size):
|
|
142
|
+
batches.append(test_items[i : i + batch_size])
|
|
143
|
+
|
|
144
|
+
_print_run_header(
|
|
145
|
+
total,
|
|
146
|
+
f"in {len(batches)} batch(es) of up to {batch_size}",
|
|
147
|
+
n_workers,
|
|
148
|
+
self.config.timeout,
|
|
149
|
+
self.config.work_dir,
|
|
150
|
+
timeout_per_test=True,
|
|
151
|
+
metadata=setup.run_metadata,
|
|
152
|
+
quiet=self.quiet,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Generate and run batch scripts
|
|
156
|
+
all_results: list[TestRunResult] = []
|
|
157
|
+
wall_start = time.monotonic()
|
|
158
|
+
|
|
159
|
+
# startup.mos reported the real Dymola version; publish it into the
|
|
160
|
+
# run provenance as soon as a batch has come back (the persistent
|
|
161
|
+
# lane does the equivalent via _probe_worker_version). Idempotent —
|
|
162
|
+
# only the first batch that yields a version stamps it.
|
|
163
|
+
stamped = [False]
|
|
164
|
+
|
|
165
|
+
def _stamp_tool_version() -> None:
|
|
166
|
+
if stamped[0]:
|
|
167
|
+
return
|
|
168
|
+
version = _read_startup_tool_version(self.config.work_dir)
|
|
169
|
+
if version:
|
|
170
|
+
stamped[0] = True
|
|
171
|
+
setup.progress.update_metadata(tool_version=version)
|
|
172
|
+
|
|
173
|
+
def _land(results: list[TestRunResult]) -> None:
|
|
174
|
+
"""Take delivery of one batch's results.
|
|
175
|
+
|
|
176
|
+
Persisting each verdict here is what gives this lane the same
|
|
177
|
+
abort-proof run record the default loop and the persistent
|
|
178
|
+
dispatcher have: ``_prepare_run`` stamped every in-scope entry
|
|
179
|
+
``pending``, and only ``record_result`` clears it. Without this
|
|
180
|
+
a completed ``run --batch`` left the whole scope ``pending`` and
|
|
181
|
+
the manifest-wins protection never materialized for it.
|
|
182
|
+
"""
|
|
183
|
+
all_results.extend(results)
|
|
184
|
+
for r in results:
|
|
185
|
+
manifest.record_result(r)
|
|
186
|
+
_stamp_tool_version()
|
|
187
|
+
|
|
188
|
+
if n_workers <= 1:
|
|
189
|
+
# Sequential: run all batches in order on a single worker
|
|
190
|
+
offset = 0
|
|
191
|
+
for batch in batches:
|
|
192
|
+
results = self._run_batch(
|
|
193
|
+
batch,
|
|
194
|
+
startup_path,
|
|
195
|
+
shutdown_path,
|
|
196
|
+
offset,
|
|
197
|
+
total,
|
|
198
|
+
worker_id=0,
|
|
199
|
+
)
|
|
200
|
+
_land(results)
|
|
201
|
+
offset += len(batch)
|
|
202
|
+
else:
|
|
203
|
+
# Parallel: submit every batch to the pool; workers pull next
|
|
204
|
+
# batch as they become free (natural load balancing + crash isolation).
|
|
205
|
+
# worker_id attribution comes from the thread name so the dashboard can
|
|
206
|
+
# group tests by actual worker slot rather than batch index.
|
|
207
|
+
import re
|
|
208
|
+
import threading
|
|
209
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
210
|
+
|
|
211
|
+
def _worker_slot() -> int:
|
|
212
|
+
m = re.search(r"(\d+)$", threading.current_thread().name)
|
|
213
|
+
return int(m.group(1)) if m else 0
|
|
214
|
+
|
|
215
|
+
def _run(batch, offset):
|
|
216
|
+
return self._run_batch(
|
|
217
|
+
batch,
|
|
218
|
+
startup_path,
|
|
219
|
+
shutdown_path,
|
|
220
|
+
offset,
|
|
221
|
+
total,
|
|
222
|
+
worker_id=_worker_slot(),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
offset = 0
|
|
226
|
+
futures = {}
|
|
227
|
+
with ThreadPoolExecutor(
|
|
228
|
+
max_workers=n_workers, thread_name_prefix="dym-worker"
|
|
229
|
+
) as pool:
|
|
230
|
+
for batch in batches:
|
|
231
|
+
future = pool.submit(_run, batch, offset)
|
|
232
|
+
futures[future] = offset
|
|
233
|
+
offset += len(batch)
|
|
234
|
+
|
|
235
|
+
for future in as_completed(futures):
|
|
236
|
+
_land(future.result())
|
|
237
|
+
|
|
238
|
+
self._print_run_summary(all_results, total, time.monotonic() - wall_start)
|
|
239
|
+
|
|
240
|
+
manifest.results = all_results
|
|
241
|
+
setup.progress.finalize()
|
|
242
|
+
return [manifest]
|
|
243
|
+
|
|
244
|
+
def _run_batch(
|
|
245
|
+
self,
|
|
246
|
+
test_items: list[tuple[TestModel, str]],
|
|
247
|
+
startup_path: Path,
|
|
248
|
+
shutdown_path: Path,
|
|
249
|
+
index_offset: int,
|
|
250
|
+
total: int,
|
|
251
|
+
worker_id: int | None = None,
|
|
252
|
+
) -> list[TestRunResult]:
|
|
253
|
+
"""Run a batch of tests in a single Dymola session."""
|
|
254
|
+
work_dir = self.config.work_dir
|
|
255
|
+
batch_id = index_offset # Use offset as unique batch ID
|
|
256
|
+
|
|
257
|
+
# Emit start events for all tests in this batch — we can't observe
|
|
258
|
+
# individual test transitions within a batched Dymola session, but the
|
|
259
|
+
# user at least knows which tests are currently in flight.
|
|
260
|
+
if self.progress is not None:
|
|
261
|
+
for _, test_key in test_items:
|
|
262
|
+
self.progress.on_start(test_key, worker_id=worker_id)
|
|
263
|
+
|
|
264
|
+
# Generate the batch script
|
|
265
|
+
batch_script = _generate_batch_mos(
|
|
266
|
+
test_items, startup_path, shutdown_path, work_dir, batch_id
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
# Calculate total timeout: per-test timeout * number of tests + startup overhead
|
|
270
|
+
startup_overhead = 120 # seconds for library loading
|
|
271
|
+
total_timeout = (
|
|
272
|
+
sum(
|
|
273
|
+
t.timeout if t.timeout is not None else self.config.timeout
|
|
274
|
+
for t, _ in test_items
|
|
275
|
+
)
|
|
276
|
+
+ startup_overhead
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Run the batch
|
|
280
|
+
start_time = time.monotonic()
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
cmd = [self.config.backend_path]
|
|
284
|
+
if not self.dymola_config.show_ide:
|
|
285
|
+
cmd.append("-nowindow")
|
|
286
|
+
cmd.append(str(batch_script))
|
|
287
|
+
|
|
288
|
+
proc = subprocess.Popen(
|
|
289
|
+
cmd,
|
|
290
|
+
stdout=subprocess.PIPE,
|
|
291
|
+
stderr=subprocess.PIPE,
|
|
292
|
+
cwd=str(work_dir),
|
|
293
|
+
)
|
|
294
|
+
stdout, stderr = proc.communicate(timeout=total_timeout)
|
|
295
|
+
batch_elapsed = time.monotonic() - start_time
|
|
296
|
+
|
|
297
|
+
except subprocess.TimeoutExpired:
|
|
298
|
+
batch_elapsed = time.monotonic() - start_time
|
|
299
|
+
# review 2026-07-06 (finding 22): dymola.exe's dymosim child
|
|
300
|
+
# inherits the pipes, so a bare kill()+communicate() blocks until
|
|
301
|
+
# dymosim exits — the timeout path itself hung. Kill the whole
|
|
302
|
+
# process tree first, then drain pipes with a bounded wait.
|
|
303
|
+
kill_process_tree(proc.pid, name_hint="dymola")
|
|
304
|
+
if proc.poll() is None:
|
|
305
|
+
# The tree kill is a no-op when psutil is missing, the name
|
|
306
|
+
# guard misses (a wrapper script's process name is not
|
|
307
|
+
# "dymola") or the walk hits AccessDenied. We own this
|
|
308
|
+
# Popen — kill it directly rather than fall into the
|
|
309
|
+
# blocking communicate() finding 22 documents.
|
|
310
|
+
try:
|
|
311
|
+
proc.kill()
|
|
312
|
+
except (ProcessLookupError, OSError):
|
|
313
|
+
pass
|
|
314
|
+
try:
|
|
315
|
+
proc.communicate(timeout=10)
|
|
316
|
+
except (subprocess.TimeoutExpired, OSError, ValueError):
|
|
317
|
+
# Last resort: close pipes without blocking on stragglers.
|
|
318
|
+
for stream in (proc.stdout, proc.stderr):
|
|
319
|
+
if stream is not None:
|
|
320
|
+
try:
|
|
321
|
+
stream.close()
|
|
322
|
+
except OSError:
|
|
323
|
+
pass
|
|
324
|
+
# review 2026-07-06 (finding 76): salvage tests that verifiably
|
|
325
|
+
# completed on disk before the batch deadline (Dymola runs tests
|
|
326
|
+
# sequentially — earlier tests in the batch usually finished);
|
|
327
|
+
# only tests without a readable, complete result are timed out.
|
|
328
|
+
results = []
|
|
329
|
+
per_elapsed = batch_elapsed / len(test_items)
|
|
330
|
+
for test, test_key in test_items:
|
|
331
|
+
test_dir = work_dir / test_key
|
|
332
|
+
completed, _why = _sim_completed_on_disk(
|
|
333
|
+
test_dir, float(test.stop_time)
|
|
334
|
+
)
|
|
335
|
+
if completed:
|
|
336
|
+
results.append(
|
|
337
|
+
TestRunResult(
|
|
338
|
+
model_id=test.model_id,
|
|
339
|
+
test_key=test_key,
|
|
340
|
+
success=True,
|
|
341
|
+
elapsed=per_elapsed,
|
|
342
|
+
statistics=parse_test_logs(test_dir),
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
if self.progress is not None:
|
|
346
|
+
self.progress.on_finish(
|
|
347
|
+
test_key,
|
|
348
|
+
success=True,
|
|
349
|
+
elapsed=per_elapsed,
|
|
350
|
+
)
|
|
351
|
+
continue
|
|
352
|
+
results.append(
|
|
353
|
+
TestRunResult(
|
|
354
|
+
model_id=test.model_id,
|
|
355
|
+
test_key=test_key,
|
|
356
|
+
success=False,
|
|
357
|
+
elapsed=per_elapsed,
|
|
358
|
+
error_message=timeout_message(
|
|
359
|
+
f"Batch timed out after {total_timeout}s"
|
|
360
|
+
),
|
|
361
|
+
timed_out=True,
|
|
362
|
+
)
|
|
363
|
+
)
|
|
364
|
+
if self.progress is not None:
|
|
365
|
+
self.progress.on_finish(
|
|
366
|
+
test_key,
|
|
367
|
+
success=False,
|
|
368
|
+
elapsed=per_elapsed,
|
|
369
|
+
detail="Batch timed out",
|
|
370
|
+
timed_out=True,
|
|
371
|
+
)
|
|
372
|
+
return results
|
|
373
|
+
|
|
374
|
+
except FileNotFoundError:
|
|
375
|
+
results = []
|
|
376
|
+
for test, test_key in test_items:
|
|
377
|
+
results.append(
|
|
378
|
+
TestRunResult(
|
|
379
|
+
model_id=test.model_id,
|
|
380
|
+
test_key=test_key,
|
|
381
|
+
success=False,
|
|
382
|
+
elapsed=0.0,
|
|
383
|
+
error_message=f"Dymola not found: {self.config.backend_path}",
|
|
384
|
+
)
|
|
385
|
+
)
|
|
386
|
+
if self.progress is not None:
|
|
387
|
+
self.progress.on_finish(
|
|
388
|
+
test_key,
|
|
389
|
+
success=False,
|
|
390
|
+
elapsed=0.0,
|
|
391
|
+
detail="Dymola not found",
|
|
392
|
+
)
|
|
393
|
+
return results
|
|
394
|
+
|
|
395
|
+
# Evaluate results per test: check if .mat file was produced
|
|
396
|
+
results = []
|
|
397
|
+
from ..base import _print_progress
|
|
398
|
+
|
|
399
|
+
for i, (test, test_key) in enumerate(test_items):
|
|
400
|
+
test_dir = work_dir / test_key
|
|
401
|
+
mat_path = test_dir / "dsres.mat"
|
|
402
|
+
short_name = test.model_id.rsplit(".", 1)[-1]
|
|
403
|
+
label = f"{test_key} {short_name}"
|
|
404
|
+
|
|
405
|
+
# Parse both dslog.txt (simulation runtime) and translation_log.txt (structural)
|
|
406
|
+
statistics = parse_test_logs(test_dir)
|
|
407
|
+
|
|
408
|
+
# Check for translation failure (defense in depth)
|
|
409
|
+
translation_failed = False
|
|
410
|
+
translation_log = test_dir / "translation_log.txt"
|
|
411
|
+
if translation_log.exists():
|
|
412
|
+
try:
|
|
413
|
+
tlog = translation_log.read_text(encoding="utf-8", errors="replace")
|
|
414
|
+
if "Translation aborted" in tlog or tlog.strip().endswith(
|
|
415
|
+
"= false"
|
|
416
|
+
):
|
|
417
|
+
translation_failed = True
|
|
418
|
+
except OSError:
|
|
419
|
+
pass
|
|
420
|
+
|
|
421
|
+
# A simulation truly completed only if BOTH:
|
|
422
|
+
# 1. dsfinal.txt exists (Dymola writes it at end of successful sim)
|
|
423
|
+
# 2. mat file's last time reaches the requested stop_time
|
|
424
|
+
# dsres.mat existence alone is insufficient — Dymola writes
|
|
425
|
+
# incrementally, so a killed/aborted sim can leave a partial file.
|
|
426
|
+
# Shared with the batch-timeout salvage path (finding 76).
|
|
427
|
+
dsfinal_path = test_dir / "dsfinal.txt"
|
|
428
|
+
sim_completed = False
|
|
429
|
+
completion_msg: str | None = None
|
|
430
|
+
if not translation_failed:
|
|
431
|
+
sim_completed, completion_msg = _sim_completed_on_disk(
|
|
432
|
+
test_dir, float(test.stop_time)
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
per_elapsed = batch_elapsed / len(test_items)
|
|
436
|
+
if sim_completed:
|
|
437
|
+
_print_progress(
|
|
438
|
+
index_offset + i + 1,
|
|
439
|
+
total,
|
|
440
|
+
label,
|
|
441
|
+
"ok",
|
|
442
|
+
elapsed=per_elapsed,
|
|
443
|
+
quiet=self.quiet,
|
|
444
|
+
)
|
|
445
|
+
results.append(
|
|
446
|
+
TestRunResult(
|
|
447
|
+
model_id=test.model_id,
|
|
448
|
+
test_key=test_key,
|
|
449
|
+
success=True,
|
|
450
|
+
elapsed=per_elapsed,
|
|
451
|
+
statistics=statistics,
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
if self.progress is not None:
|
|
455
|
+
self.progress.on_finish(
|
|
456
|
+
test_key,
|
|
457
|
+
success=True,
|
|
458
|
+
elapsed=per_elapsed,
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
if translation_failed:
|
|
462
|
+
msg = "Translation failed"
|
|
463
|
+
elif not mat_path.exists():
|
|
464
|
+
msg = "No result file produced"
|
|
465
|
+
elif not dsfinal_path.exists():
|
|
466
|
+
msg = "Simulation aborted (no dsfinal.txt)"
|
|
467
|
+
else:
|
|
468
|
+
msg = completion_msg or "Simulation incomplete"
|
|
469
|
+
# Try to get error from dslog
|
|
470
|
+
dslog_path = test_dir / "dslog.txt"
|
|
471
|
+
if dslog_path.exists():
|
|
472
|
+
try:
|
|
473
|
+
log_text = dslog_path.read_text(
|
|
474
|
+
encoding="utf-8", errors="replace"
|
|
475
|
+
)
|
|
476
|
+
if "ERROR" in log_text or "error" in log_text:
|
|
477
|
+
lines = log_text.strip().split("\n")
|
|
478
|
+
msg = msg + " | " + " | ".join(lines[-3:])
|
|
479
|
+
except OSError:
|
|
480
|
+
pass
|
|
481
|
+
|
|
482
|
+
_print_progress(
|
|
483
|
+
index_offset + i + 1,
|
|
484
|
+
total,
|
|
485
|
+
label,
|
|
486
|
+
"FAIL",
|
|
487
|
+
elapsed=per_elapsed,
|
|
488
|
+
detail=msg[:80],
|
|
489
|
+
quiet=self.quiet,
|
|
490
|
+
)
|
|
491
|
+
results.append(
|
|
492
|
+
TestRunResult(
|
|
493
|
+
model_id=test.model_id,
|
|
494
|
+
test_key=test_key,
|
|
495
|
+
success=False,
|
|
496
|
+
elapsed=per_elapsed,
|
|
497
|
+
error_message=msg,
|
|
498
|
+
statistics=statistics,
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
if self.progress is not None:
|
|
502
|
+
self.progress.on_finish(
|
|
503
|
+
test_key,
|
|
504
|
+
success=False,
|
|
505
|
+
elapsed=per_elapsed,
|
|
506
|
+
detail=msg[:120],
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return results
|
|
510
|
+
|
|
511
|
+
def read_result(
|
|
512
|
+
self,
|
|
513
|
+
test: TestModel,
|
|
514
|
+
test_key: str,
|
|
515
|
+
run_result: TestRunResult | None,
|
|
516
|
+
) -> TestResult:
|
|
517
|
+
test_dir = self.config.work_dir / test_key
|
|
518
|
+
# The run's own statistics (in process, or kept in run_stats.json)
|
|
519
|
+
# win. Without them the logs Dymola left beside the result are read:
|
|
520
|
+
# code that reads files tolerates a result written before
|
|
521
|
+
# run_stats.json existed (D138), and such a result still has both.
|
|
522
|
+
# They hold no phase walls, so nothing here invents a timing block.
|
|
523
|
+
stats = (
|
|
524
|
+
dict(run_result.statistics)
|
|
525
|
+
if run_result and run_result.statistics
|
|
526
|
+
else parse_test_logs(test_dir)
|
|
527
|
+
)
|
|
528
|
+
mat_path = test_dir / "dsres.mat"
|
|
529
|
+
|
|
530
|
+
if not mat_path.exists():
|
|
531
|
+
return TestResult(
|
|
532
|
+
model_id=test.model_id,
|
|
533
|
+
success=False,
|
|
534
|
+
error_message=f"Result file not found: {mat_path}",
|
|
535
|
+
statistics=stats,
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
# Phase 1: Get variable names (fast — reads only the name matrix)
|
|
539
|
+
# Then resolve patterns to determine which variables we actually need.
|
|
540
|
+
needed_vars = compute_needed_variables(
|
|
541
|
+
mat_path,
|
|
542
|
+
test,
|
|
543
|
+
self.dymola_config.diagnostic_variables,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
# Phase 2: Load only needed variables from the .mat file
|
|
547
|
+
mat_data = read_result_mat(mat_path, variable_names=needed_vars)
|
|
548
|
+
if mat_data is None:
|
|
549
|
+
return TestResult(
|
|
550
|
+
model_id=test.model_id,
|
|
551
|
+
success=False,
|
|
552
|
+
error_message=f"Failed to parse: {mat_path}",
|
|
553
|
+
statistics=stats,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
variables, diagnostics = extract_variables(
|
|
557
|
+
mat_data,
|
|
558
|
+
test,
|
|
559
|
+
self.dymola_config.diagnostic_variables,
|
|
560
|
+
)
|
|
561
|
+
stats = fold_diagnostics_into_statistics(stats, diagnostics)
|
|
562
|
+
|
|
563
|
+
unfinished = unfinished_result(test, mat_path, variables, diagnostics, stats)
|
|
564
|
+
if unfinished is not None:
|
|
565
|
+
return unfinished
|
|
566
|
+
|
|
567
|
+
return TestResult(
|
|
568
|
+
model_id=test.model_id,
|
|
569
|
+
success=True,
|
|
570
|
+
variables=variables,
|
|
571
|
+
diagnostics=diagnostics,
|
|
572
|
+
statistics=stats,
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
@classmethod
|
|
576
|
+
def result_names(cls, test_dir: Path) -> list[str] | None:
|
|
577
|
+
return result_files.mat_names(test_dir / "dsres.mat")
|
|
578
|
+
|
|
579
|
+
@classmethod
|
|
580
|
+
def result_series(cls, test_dir, names):
|
|
581
|
+
return result_files.mat_series(test_dir / "dsres.mat", names)
|
|
582
|
+
|
|
583
|
+
@classmethod
|
|
584
|
+
def live_result_path(cls, test_dir: Path) -> Path:
|
|
585
|
+
return test_dir / "dsres.mat"
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
# ---------------------------------------------------------------------------
|
|
589
|
+
# .mos script generation
|
|
590
|
+
# ---------------------------------------------------------------------------
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
#: startup.mos writes ``DymolaVersion()`` here. The batch lane has no live
|
|
594
|
+
#: session to ask for a version (that is what the persistent lane's
|
|
595
|
+
#: ``_probe_worker_version`` is for), and launching a Dymola purely to probe
|
|
596
|
+
#: would cost a licence seat and tens of seconds — so the script that is
|
|
597
|
+
#: already being run does the reporting, and the backend reads the file back
|
|
598
|
+
#: once a batch has returned. No file (MSL not loaded, Dymola never started)
|
|
599
|
+
#: leaves ``tool_version`` null, exactly as before.
|
|
600
|
+
TOOL_VERSION_FILENAME = "dymola_version.txt"
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _read_startup_tool_version(work_dir: Path) -> str | None:
|
|
604
|
+
"""First non-empty line of the version file startup.mos wrote, if any.
|
|
605
|
+
|
|
606
|
+
Every worker's startup appends the same string, so the first line is the
|
|
607
|
+
answer; a missing or unreadable file means "no honest version available".
|
|
608
|
+
"""
|
|
609
|
+
try:
|
|
610
|
+
text = (work_dir / TOOL_VERSION_FILENAME).read_text(
|
|
611
|
+
encoding="utf-8", errors="replace"
|
|
612
|
+
)
|
|
613
|
+
except OSError:
|
|
614
|
+
return None
|
|
615
|
+
for line in text.splitlines():
|
|
616
|
+
if line.strip():
|
|
617
|
+
return line.strip()
|
|
618
|
+
return None
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _generate_startup_mos(config: Config, dymola_config: DymolaConfig) -> Path:
|
|
622
|
+
"""Generate startup.mos: load dependencies, main library, and setup commands."""
|
|
623
|
+
work_dir = config.work_dir
|
|
624
|
+
# Drop any version file a previous run in this work dir left behind — a
|
|
625
|
+
# stale line would be read back as THIS run's provenance.
|
|
626
|
+
(work_dir / TOOL_VERSION_FILENAME).unlink(missing_ok=True)
|
|
627
|
+
lines = [
|
|
628
|
+
"// Startup: load libraries and configure environment",
|
|
629
|
+
f'cd("{work_dir.as_posix()}");',
|
|
630
|
+
]
|
|
631
|
+
|
|
632
|
+
# Load dependencies first
|
|
633
|
+
for dep_path in config.dependencies:
|
|
634
|
+
dep_pkg = dependency_file(dep_path)
|
|
635
|
+
lines.append(f'openModel("{dep_pkg.as_posix()}");')
|
|
636
|
+
|
|
637
|
+
# Load main library
|
|
638
|
+
lines.append(f'openModel("{(config.library_dir / "package.mo").as_posix()}");')
|
|
639
|
+
|
|
640
|
+
# Dymola-specific framework settings (always enabled)
|
|
641
|
+
lines.append("")
|
|
642
|
+
lines.append("// Framework settings")
|
|
643
|
+
lines.append("OutputCPUtime := true;")
|
|
644
|
+
lines.append("Advanced.UI.TranslationInCommandLog := true;")
|
|
645
|
+
|
|
646
|
+
# Run provenance: report the actual tool version to a file the backend
|
|
647
|
+
# reads back (see TOOL_VERSION_FILENAME). Cheap — it rides along in a
|
|
648
|
+
# script that has to run anyway.
|
|
649
|
+
version_file = (work_dir / TOOL_VERSION_FILENAME).as_posix()
|
|
650
|
+
lines.append("")
|
|
651
|
+
lines.append("// Run provenance (read back by the backend)")
|
|
652
|
+
lines.append(
|
|
653
|
+
f'Modelica.Utilities.Streams.print(DymolaVersion(), "{version_file}");'
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
# Backend setup commands (e.g., OutputCPUtime = true)
|
|
657
|
+
if dymola_config.backend_setup:
|
|
658
|
+
lines.append("")
|
|
659
|
+
lines.append("// Backend setup")
|
|
660
|
+
for cmd in dymola_config.backend_setup:
|
|
661
|
+
# Ensure command ends with semicolon
|
|
662
|
+
cmd = cmd.strip()
|
|
663
|
+
if not cmd.endswith(";"):
|
|
664
|
+
cmd += ";"
|
|
665
|
+
lines.append(cmd)
|
|
666
|
+
|
|
667
|
+
script_path = work_dir / "startup.mos"
|
|
668
|
+
script_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
669
|
+
return script_path
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
def _generate_shutdown_mos(config: Config) -> Path:
|
|
673
|
+
"""Generate shutdown.mos: exit Dymola."""
|
|
674
|
+
lines = [
|
|
675
|
+
"// Shutdown",
|
|
676
|
+
"Modelica.Utilities.System.exit();",
|
|
677
|
+
]
|
|
678
|
+
script_path = config.work_dir / "shutdown.mos"
|
|
679
|
+
script_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
680
|
+
return script_path
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _generate_test_mos(
|
|
684
|
+
test: TestModel,
|
|
685
|
+
test_key: str,
|
|
686
|
+
test_dir: Path,
|
|
687
|
+
) -> Path:
|
|
688
|
+
"""Generate a per-test .mos script: cd to test dir and simulate."""
|
|
689
|
+
parts = [f'"{test.model_id}"']
|
|
690
|
+
|
|
691
|
+
if test.stop_time != 1.0:
|
|
692
|
+
if test.stop_time == int(test.stop_time):
|
|
693
|
+
parts.append(f"stopTime={int(test.stop_time)}")
|
|
694
|
+
else:
|
|
695
|
+
parts.append(f"stopTime={test.stop_time}")
|
|
696
|
+
|
|
697
|
+
# numberOfIntervals takes precedence over outputInterval if both are set
|
|
698
|
+
if test.number_of_intervals is not None:
|
|
699
|
+
parts.append(f"numberOfIntervals={test.number_of_intervals}")
|
|
700
|
+
elif test.output_interval is not None:
|
|
701
|
+
parts.append(f"outputInterval={test.output_interval}")
|
|
702
|
+
|
|
703
|
+
parts.append(f'method="{test.method}"')
|
|
704
|
+
parts.append(f"tolerance={test.tolerance}")
|
|
705
|
+
parts.append('resultFile="dsres"')
|
|
706
|
+
|
|
707
|
+
lines = [
|
|
708
|
+
f"// {test.model_id}",
|
|
709
|
+
f'cd("{test_dir.as_posix()}");',
|
|
710
|
+
"clearlog();",
|
|
711
|
+
f"simulateModel({','.join(parts)});",
|
|
712
|
+
'savelog("translation_log.txt");',
|
|
713
|
+
]
|
|
714
|
+
|
|
715
|
+
script_path = test_dir / "simulate.mos"
|
|
716
|
+
script_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
717
|
+
return script_path
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _generate_batch_mos(
|
|
721
|
+
test_items: list[tuple[TestModel, str]],
|
|
722
|
+
startup_path: Path,
|
|
723
|
+
shutdown_path: Path,
|
|
724
|
+
work_dir: Path,
|
|
725
|
+
batch_id: int,
|
|
726
|
+
) -> Path:
|
|
727
|
+
"""Generate a batch .mos script that runs startup, all tests, then shutdown."""
|
|
728
|
+
lines = [
|
|
729
|
+
f"// Batch {batch_id}: {len(test_items)} tests",
|
|
730
|
+
f'RunScript("{startup_path.as_posix()}");',
|
|
731
|
+
]
|
|
732
|
+
|
|
733
|
+
for _test, test_key in test_items:
|
|
734
|
+
test_mos = work_dir / test_key / "simulate.mos"
|
|
735
|
+
lines.append(f'RunScript("{test_mos.as_posix()}");')
|
|
736
|
+
|
|
737
|
+
lines.append(f'RunScript("{shutdown_path.as_posix()}");')
|
|
738
|
+
|
|
739
|
+
script_path = work_dir / f"batch_{batch_id:04d}.mos"
|
|
740
|
+
script_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
741
|
+
return script_path
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# ---------------------------------------------------------------------------
|
|
745
|
+
# Process / on-disk state helpers
|
|
746
|
+
# ---------------------------------------------------------------------------
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _sim_completed_on_disk(
|
|
750
|
+
test_dir: Path,
|
|
751
|
+
stop_time: float,
|
|
752
|
+
) -> tuple[bool, str | None]:
|
|
753
|
+
"""Check on-disk artifacts for a truly completed simulation.
|
|
754
|
+
|
|
755
|
+
Completed iff dsfinal.txt exists AND dsres.mat's last time reaches
|
|
756
|
+
stop_time (dsres.mat alone is insufficient — Dymola writes incrementally,
|
|
757
|
+
so a killed sim leaves a partial file). Returns ``(completed, why_not)``;
|
|
758
|
+
``why_not`` is only set when artifacts exist but are short/unreadable.
|
|
759
|
+
Used by both the normal batch evaluation and the batch-timeout salvage
|
|
760
|
+
path (review 2026-07-06, finding 76).
|
|
761
|
+
"""
|
|
762
|
+
from ..common.mat_reader import read_mat_time_extents
|
|
763
|
+
|
|
764
|
+
mat_path = test_dir / "dsres.mat"
|
|
765
|
+
dsfinal_path = test_dir / "dsfinal.txt"
|
|
766
|
+
if not mat_path.exists() or not dsfinal_path.exists():
|
|
767
|
+
return False, None
|
|
768
|
+
extents = read_mat_time_extents(mat_path)
|
|
769
|
+
if extents is None:
|
|
770
|
+
return False, "dsres.mat unreadable"
|
|
771
|
+
last_time = extents[1]
|
|
772
|
+
# Allow a tiny tolerance — output_interval may not align perfectly
|
|
773
|
+
tol = max(1e-6, abs(stop_time) * 1e-6)
|
|
774
|
+
if last_time + tol >= stop_time:
|
|
775
|
+
return True, None
|
|
776
|
+
return False, f"Stopped early at T={last_time:.6g} of {stop_time:.6g}"
|