Implemented live updates using the growing MAT file.
This commit is contained in:
@@ -6,7 +6,7 @@ from .openmodelica import (
|
|||||||
ProcessResult,
|
ProcessResult,
|
||||||
SimulationCancelledError,
|
SimulationCancelledError,
|
||||||
)
|
)
|
||||||
from .results import SimulationResult, load_openmodelica_csv, load_openmodelica_mat
|
from .results import SimulationResult, load_openmodelica_csv, load_openmodelica_mat, load_openmodelica_mat_snapshot
|
||||||
from .compile import compile_component, compile_component_sync
|
from .compile import compile_component, compile_component_sync
|
||||||
from .simulation import (
|
from .simulation import (
|
||||||
ModelBuildResult,
|
ModelBuildResult,
|
||||||
@@ -39,5 +39,6 @@ __all__ = [
|
|||||||
"compile_component_sync",
|
"compile_component_sync",
|
||||||
"load_openmodelica_csv",
|
"load_openmodelica_csv",
|
||||||
"load_openmodelica_mat",
|
"load_openmodelica_mat",
|
||||||
|
"load_openmodelica_mat_snapshot",
|
||||||
"simulate",
|
"simulate",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
import io
|
||||||
|
import struct
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -54,15 +56,66 @@ def load_openmodelica_mat(path: str | Path) -> dict[str, list[float]]:
|
|||||||
raise ValueError("reading OpenModelica MAT results requires DyMat") from None
|
raise ValueError("reading OpenModelica MAT results requires DyMat") from None
|
||||||
try:
|
try:
|
||||||
result = DyMatFile(str(Path(path)))
|
result = DyMatFile(str(Path(path)))
|
||||||
blocks = result.blocks()
|
columns = _dymat_columns(result)
|
||||||
time_block = 2 if 2 in blocks else blocks[-1]
|
|
||||||
times = result.abscissa(time_block, valuesOnly=True).astype(float).tolist()
|
|
||||||
columns = {"time": times}
|
|
||||||
for name in result.names():
|
|
||||||
values = result.data(name).astype(float).tolist()
|
|
||||||
if len(values) != len(times):
|
|
||||||
values = [values[0]] * len(times)
|
|
||||||
columns[name] = values
|
|
||||||
except (IndexError, KeyError, OSError, TypeError, ValueError) as error:
|
except (IndexError, KeyError, OSError, TypeError, ValueError) as error:
|
||||||
raise ValueError(f"could not read OpenModelica MAT results: {error}") from error
|
raise ValueError(f"could not read OpenModelica MAT results: {error}") from error
|
||||||
return columns
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def load_openmodelica_mat_snapshot(path: str | Path) -> dict[str, list[float]]:
|
||||||
|
"""Read the complete time-step columns currently available in a growing MAT file."""
|
||||||
|
try:
|
||||||
|
from DyMat import DyMatFile
|
||||||
|
except ImportError:
|
||||||
|
raise ValueError("reading OpenModelica MAT results requires DyMat") from None
|
||||||
|
try:
|
||||||
|
snapshot = bytearray(Path(path).read_bytes())
|
||||||
|
header_offset, byte_order, rows, columns, element_size, data_offset = _mat_v4_matrix(snapshot, "data_2")
|
||||||
|
available_columns = (len(snapshot) - data_offset) // (rows * element_size)
|
||||||
|
complete_columns = min(columns, available_columns) if columns > 0 else available_columns
|
||||||
|
if complete_columns < 2:
|
||||||
|
raise ValueError("the MAT result does not contain enough complete samples yet")
|
||||||
|
struct.pack_into(f"{byte_order}i", snapshot, header_offset + 8, complete_columns)
|
||||||
|
del snapshot[data_offset + complete_columns * rows * element_size:]
|
||||||
|
columns_data = _dymat_columns(DyMatFile(io.BytesIO(snapshot)))
|
||||||
|
except (IndexError, KeyError, OSError, TypeError, ValueError, struct.error) as error:
|
||||||
|
raise ValueError(f"could not read growing OpenModelica MAT results: {error}") from error
|
||||||
|
return columns_data
|
||||||
|
|
||||||
|
|
||||||
|
def _dymat_columns(result) -> dict[str, list[float]]:
|
||||||
|
blocks = result.blocks()
|
||||||
|
time_block = 2 if 2 in blocks else blocks[-1]
|
||||||
|
times = result.abscissa(time_block, valuesOnly=True).astype(float).tolist()
|
||||||
|
columns = {"time": times}
|
||||||
|
for name in result.names():
|
||||||
|
values = result.data(name).astype(float).tolist()
|
||||||
|
if len(values) != len(times):
|
||||||
|
values = [values[0]] * len(times)
|
||||||
|
columns[name] = values
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def _mat_v4_matrix(content: bytearray, target: str) -> tuple[int, str, int, int, int, int]:
|
||||||
|
offset = 0
|
||||||
|
while offset + 20 <= len(content):
|
||||||
|
byte_order = _mat_v4_byte_order(content, offset)
|
||||||
|
matrix_type, rows, columns, imaginary, name_length = struct.unpack_from(f"{byte_order}5i", content, offset)
|
||||||
|
if rows <= 0 or columns < 0 or imaginary not in (0, 1) or name_length <= 0:
|
||||||
|
break
|
||||||
|
name_offset = offset + 20
|
||||||
|
data_offset = name_offset + name_length
|
||||||
|
name = bytes(content[name_offset:data_offset]).split(b"\x00", 1)[0].decode("ascii", errors="replace")
|
||||||
|
precision = (matrix_type // 10) % 10
|
||||||
|
element_size = {0: 8, 1: 4, 2: 4, 3: 2, 4: 2, 5: 1}.get(precision)
|
||||||
|
if element_size is None:
|
||||||
|
break
|
||||||
|
if name == target:
|
||||||
|
return offset, byte_order, rows, columns, element_size, data_offset
|
||||||
|
offset = data_offset + rows * columns * element_size * (2 if imaginary else 1)
|
||||||
|
raise ValueError(f"MAT v4 matrix {target!r} was not found")
|
||||||
|
|
||||||
|
|
||||||
|
def _mat_v4_byte_order(content: bytearray, offset: int) -> str:
|
||||||
|
little_type = struct.unpack_from("<i", content, offset)[0]
|
||||||
|
return "<" if little_type // 1000 in (0, 1) and (little_type // 10) % 10 <= 5 else ">"
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ from .openmodelica import OpenModelicaRunner
|
|||||||
from .results import SimulationResult
|
from .results import SimulationResult
|
||||||
from .simulation import Simulation, SimulationOptions, SimulationStateError
|
from .simulation import Simulation, SimulationOptions, SimulationStateError
|
||||||
|
|
||||||
_SEGMENTS_PER_RUN = 10
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class SimulationRunSettings:
|
class SimulationRunSettings:
|
||||||
start_time: float = 0.0
|
start_time: float = 0.0
|
||||||
@@ -42,63 +39,54 @@ class SimulationSession:
|
|||||||
self.results = list(results)
|
self.results = list(results)
|
||||||
self._simulation = Simulation(runner)
|
self._simulation = Simulation(runner)
|
||||||
self._simulation.load_compiled(model_name, executable)
|
self._simulation.load_compiled(model_name, executable)
|
||||||
self._segments_completed = 0
|
|
||||||
self._segments_total = 1
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
return self._simulation.is_running
|
return self._simulation.is_running
|
||||||
|
|
||||||
def get_progress(self) -> int:
|
def get_progress(self) -> int:
|
||||||
return min(100, int((self._segments_completed + self._simulation.get_progress() / 100) * 100 / self._segments_total))
|
return self._simulation.get_progress()
|
||||||
|
|
||||||
async def run_next(self) -> SimulationResult:
|
async def run_next(self) -> SimulationResult:
|
||||||
return await self.run_next_segmented()
|
return await self.run_next_segmented()
|
||||||
|
|
||||||
async def run_next_segmented(self, segment_completed: Callable[[SimulationResult], None] | None = None) -> SimulationResult:
|
async def run_next_segmented(self, segment_completed: Callable[[SimulationResult], None] | None = None) -> SimulationResult:
|
||||||
"""Advance one configured duration in restartable, plot-ready segments."""
|
"""Advance one configured duration in one process with live result snapshots."""
|
||||||
previous_file = self._restart_file()
|
previous_file = self._restart_file()
|
||||||
if previous_file is None and self.current_end_time != self.settings.start_time:
|
if previous_file is None and self.current_end_time != self.settings.start_time:
|
||||||
self.current_end_time = self.settings.start_time
|
self.current_end_time = self.settings.start_time
|
||||||
self.results.clear()
|
self.results.clear()
|
||||||
run_start_time = self.current_end_time
|
start_time = self.current_end_time
|
||||||
run_stop_time = run_start_time + self.settings.duration
|
stop_time = start_time + self.settings.duration
|
||||||
segment_duration = self.settings.duration / _SEGMENTS_PER_RUN
|
completed_results = list(self.results)
|
||||||
intervals = self._segment_intervals(segment_duration)
|
result_file = self._simulation.last_build.executable.parent / f"{self._simulation.last_build.executable.stem}_segment_{len(completed_results):06d}.mat"
|
||||||
self._segments_completed = 0
|
|
||||||
self._segments_total = _SEGMENTS_PER_RUN
|
def publish(result: SimulationResult) -> None:
|
||||||
result: SimulationResult | None = None
|
self.results[:] = [*completed_results, result]
|
||||||
for segment in range(_SEGMENTS_PER_RUN):
|
|
||||||
start_time = self.current_end_time
|
|
||||||
stop_time = run_stop_time if segment == _SEGMENTS_PER_RUN - 1 else run_start_time + (segment + 1) * segment_duration
|
|
||||||
result_file = self._simulation.last_build.executable.parent / f"{self._simulation.last_build.executable.stem}_segment_{len(self.results):06d}.mat"
|
|
||||||
options = SimulationOptions(
|
|
||||||
start_time=start_time,
|
|
||||||
stop_time=stop_time,
|
|
||||||
number_of_intervals=intervals,
|
|
||||||
tolerance=self.settings.tolerance,
|
|
||||||
method=self.settings.method,
|
|
||||||
result_file=result_file,
|
|
||||||
initialization_file=previous_file,
|
|
||||||
initialization_time=start_time if previous_file is not None else None,
|
|
||||||
skip_initialization=previous_file is not None,
|
|
||||||
)
|
|
||||||
result = await self._simulation.run(options)
|
|
||||||
self.current_end_time = stop_time
|
|
||||||
self.results.append(result)
|
|
||||||
previous_file = result.result_file
|
|
||||||
self._segments_completed += 1
|
|
||||||
if segment_completed is not None:
|
if segment_completed is not None:
|
||||||
segment_completed(result)
|
segment_completed(result)
|
||||||
assert result is not None
|
|
||||||
self._segments_total = 1
|
options = SimulationOptions(
|
||||||
self._segments_completed = 1
|
start_time=start_time,
|
||||||
|
stop_time=stop_time,
|
||||||
|
number_of_intervals=self._run_intervals(),
|
||||||
|
tolerance=self.settings.tolerance,
|
||||||
|
method=self.settings.method,
|
||||||
|
result_file=result_file,
|
||||||
|
initialization_file=previous_file,
|
||||||
|
initialization_time=start_time if previous_file is not None else None,
|
||||||
|
skip_initialization=previous_file is not None,
|
||||||
|
intermediate_result=publish,
|
||||||
|
)
|
||||||
|
result = await self._simulation.run(options)
|
||||||
|
self.current_end_time = stop_time
|
||||||
|
self.results[:] = [*completed_results, result]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _segment_intervals(self, segment_duration: float) -> int:
|
def _run_intervals(self) -> int:
|
||||||
if self.settings.use_timed_steps:
|
if self.settings.use_timed_steps:
|
||||||
return max(1, math.ceil(segment_duration / self.settings.step_size))
|
return max(1, math.ceil(self.settings.duration / self.settings.step_size))
|
||||||
return max(1, math.ceil(self.settings.number_of_steps / _SEGMENTS_PER_RUN))
|
return self.settings.number_of_steps
|
||||||
|
|
||||||
def _restart_file(self) -> Path | None:
|
def _restart_file(self) -> Path | None:
|
||||||
for result in reversed(self.results):
|
for result in reversed(self.results):
|
||||||
@@ -114,5 +102,3 @@ class SimulationSession:
|
|||||||
raise SimulationStateError("cannot reset while a simulation is running")
|
raise SimulationStateError("cannot reset while a simulation is running")
|
||||||
self.current_end_time = self.settings.start_time
|
self.current_end_time = self.settings.start_time
|
||||||
self.results.clear()
|
self.results.clear()
|
||||||
self._segments_completed = 0
|
|
||||||
self._segments_total = 1
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import time
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections.abc import Sequence
|
from collections.abc import Callable, Sequence
|
||||||
from threading import Event, Lock, Thread
|
from threading import Event, Lock, Thread
|
||||||
|
|
||||||
from bedit_core.modelica import CompositionResult, compose_modelica_model
|
from bedit_core.modelica import CompositionResult, compose_modelica_model
|
||||||
@@ -22,7 +22,7 @@ from .openmodelica import (
|
|||||||
ProcessResult,
|
ProcessResult,
|
||||||
_run_on_worker,
|
_run_on_worker,
|
||||||
)
|
)
|
||||||
from .results import SimulationResult, load_openmodelica_csv, load_openmodelica_mat
|
from .results import SimulationResult, load_openmodelica_csv, load_openmodelica_mat, load_openmodelica_mat_snapshot
|
||||||
|
|
||||||
_MODEL_FILE = "model.mo"
|
_MODEL_FILE = "model.mo"
|
||||||
_RUN_SCRIPT_FILE = "run.mos"
|
_RUN_SCRIPT_FILE = "run.mos"
|
||||||
@@ -43,6 +43,7 @@ class SimulationOptions:
|
|||||||
initialization_file: str | Path | None = None
|
initialization_file: str | Path | None = None
|
||||||
initialization_time: float | None = None
|
initialization_time: float | None = None
|
||||||
skip_initialization: bool = False
|
skip_initialization: bool = False
|
||||||
|
intermediate_result: Callable[[SimulationResult], None] | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.stop_time <= self.start_time:
|
if self.stop_time <= self.start_time:
|
||||||
@@ -518,7 +519,7 @@ class Simulation:
|
|||||||
|
|
||||||
reader = Thread(
|
reader = Thread(
|
||||||
target=self._read_progress,
|
target=self._read_progress,
|
||||||
args=(server, command_finished, reader_errors),
|
args=(server, command_finished, reader_errors, options.result_file, self._model_name, options.intermediate_result),
|
||||||
name="bedit-simulation-progress",
|
name="bedit-simulation-progress",
|
||||||
daemon=True,
|
daemon=True,
|
||||||
)
|
)
|
||||||
@@ -568,6 +569,9 @@ class Simulation:
|
|||||||
server: socket.socket,
|
server: socket.socket,
|
||||||
command_finished: Event,
|
command_finished: Event,
|
||||||
errors: list[BaseException],
|
errors: list[BaseException],
|
||||||
|
result_file: str | Path | None,
|
||||||
|
model_name: str | None,
|
||||||
|
intermediate_result: Callable[[SimulationResult], None] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
connection: socket.socket | None = None
|
connection: socket.socket | None = None
|
||||||
@@ -586,6 +590,7 @@ class Simulation:
|
|||||||
raise OpenModelicaError(
|
raise OpenModelicaError(
|
||||||
"simulation did not connect to the progress server"
|
"simulation did not connect to the progress server"
|
||||||
)
|
)
|
||||||
|
next_snapshot = 10
|
||||||
with connection, connection.makefile("r", encoding="utf-8") as stream:
|
with connection, connection.makefile("r", encoding="utf-8") as stream:
|
||||||
for line in stream:
|
for line in stream:
|
||||||
try:
|
try:
|
||||||
@@ -595,9 +600,25 @@ class Simulation:
|
|||||||
# messages. Keep consuming the stream so the simulator
|
# messages. Keep consuming the stream so the simulator
|
||||||
# does not receive SIGPIPE.
|
# does not receive SIGPIPE.
|
||||||
continue
|
continue
|
||||||
|
percentage = self.get_progress()
|
||||||
|
if intermediate_result is not None and percentage >= next_snapshot:
|
||||||
|
self._publish_intermediate_result(result_file, model_name, intermediate_result)
|
||||||
|
next_snapshot = (percentage // 10 + 1) * 10
|
||||||
except (OSError, OpenModelicaError) as error:
|
except (OSError, OpenModelicaError) as error:
|
||||||
errors.append(error)
|
errors.append(error)
|
||||||
|
|
||||||
|
def _publish_intermediate_result(self, result_file: str | Path | None, model_name: str | None, callback: Callable[[SimulationResult], None]) -> None:
|
||||||
|
if result_file is None or model_name is None:
|
||||||
|
return
|
||||||
|
path = Path(result_file)
|
||||||
|
if not path.is_file() or path.suffix.lower() != ".mat":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
data = load_openmodelica_mat_snapshot(path)
|
||||||
|
except Exception: # noqa: BLE001 - the MAT file may be incomplete while the simulator writes it
|
||||||
|
return
|
||||||
|
callback(SimulationResult(model_name=model_name, data=data, result_file=path))
|
||||||
|
|
||||||
def _parse_progress_line(self, line: str) -> None:
|
def _parse_progress_line(self, line: str) -> None:
|
||||||
element = ET.fromstring(line.strip())
|
element = ET.fromstring(line.strip())
|
||||||
if element.tag != "status":
|
if element.tag != "status":
|
||||||
|
|||||||
+45756
-7624
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user