diff --git a/AGENTS.md b/AGENTS.md index 6a56bc9..a258f63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,6 +161,18 @@ Text widgets use their native `copy()`, `cut()`, and `paste()` methods. Componen - Scope destructive shortcuts to the relevant widget where appropriate. - Always guard the operation itself even when an action is disabled for presentation. +## Simulator application + +- `bedit_gui/simulation_application.py` is the composition root for the separate `bedit-sim` Qt application. +- Keep compilation GUI-independent. Shared compile entry points belong in `bedit_simulation` and must not create or depend on a `QApplication` or window. +- The simulator opens BEdit `.beb`/`.json` documents or serialized simulation `.bes`/`.json` files. +- A component selector uses default simulation settings. A stored simulation-settings block already identifies its component; do not require both selectors. +- `SimulationDatabase.active_simulation` is the persisted active settings ID selected when the Simulation Settings dialog is accepted. +- Simulation files serialize `bedit_gui.simulation_models.SimulationRoot`. JSON uses the `root_type = "simulation_root"` discriminator; binary `.bes` files use their own BES header. +- `SimulationRoot` contains source-document identity, the selected component and copied settings, and eventually results. Compiled artifacts are transient application state and must not be serialized; reopen saved simulations by compiling them again. Never serialize live Python objects. +- BEdit launches the simulator as a separate process. Compile/Open Simulation integration may transfer a `.bes` file to that process. +- Keep simulator file workflows in their own services/controllers rather than adding them to `MainWindow` or the editor document controller. + ## Verification The old test suite and its VS Code/packaging references were deliberately removed. Do not recreate a test suite unless asked. diff --git a/pyproject.toml b/pyproject.toml index 5bbcdc0..1e95b53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,7 @@ dependencies = [ bedit-graphviz = "bedit_util.graphviz:main" bedit-simulate = "bedit_util.simulate:main" bedit = "bedit_gui.application:main" -bedit-sim = "bedit_gui.simulation_application:main" +besim = "bedit_gui.simulation_application:main" [project.optional-dependencies] dev = [ diff --git a/simulation.besim.json b/simulation.besim.json new file mode 100644 index 0000000..ec9ad8b --- /dev/null +++ b/simulation.besim.json @@ -0,0 +1,21 @@ +{ + "file_format_version": 1, + "root_type": "simulation_root", + "format_version": 1, + "source_document": "/home/joppe/Projects/BEdit/untitled.bedit.json", + "source_document_id": "3b6780c7-488b-471e-a784-392db7632090", + "component": "50e6ef97-f686-4400-bc01-e5a352e8cc22", + "component_path": "some_bondgraph", + "settings_name": "run bondgraph", + "settings": { + "component": "50e6ef97-f686-4400-bc01-e5a352e8cc22", + "name": "run bondgraph", + "start_time": 0.0, + "duration": 10.0, + "use_timed_steps": true, + "number_of_steps": 500, + "step_size": 0.001, + "method": "dassl", + "dassl_tolerance": 1e-06 + } +} diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py index f2be61c..c2ea6fb 100644 --- a/src/bedit_gui/application.py +++ b/src/bedit_gui/application.py @@ -10,6 +10,7 @@ from bedit_gui.controllers.document_controller import DocumentController from bedit_gui.controllers.log_controller import LogController from bedit_gui.controllers.settings_controller import SettingsController from bedit_gui.controllers.simulation_settings_controller import SimulationSettingsController +from bedit_gui.controllers.simulation_controller import SimulationController from bedit_gui.controllers.undo_controller import UndoController from bedit_gui.controllers.view_menu_controller import ViewMenuController from bedit_gui.controllers.window_state_controller import WindowStateController @@ -48,6 +49,7 @@ def main() -> int: DocumentController(document, window) SettingsController(window, settings) SimulationSettingsController(document, window) + SimulationController(document, window) UndoController(document, window) ViewMenuController(window) document_tree_controller = DocumentTreeController(document, window) diff --git a/src/bedit_gui/controllers/simulation_controller.py b/src/bedit_gui/controllers/simulation_controller.py new file mode 100644 index 0000000..8c071b1 --- /dev/null +++ b/src/bedit_gui/controllers/simulation_controller.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import sys +import tempfile +from collections.abc import Callable +from pathlib import Path + +from PySide6.QtCore import QObject, QProcess, Qt +from PySide6.QtWidgets import QApplication, QMessageBox + +from bedit_core.models import Component +from bedit_gui.documents import Document +from bedit_gui.services import simulation_files +from bedit_gui.services.simulation_loader import component_choices +from bedit_gui.simulation_models import CompiledModel, SimulationRoot +from bedit_gui.views.main_window import MainWindow +from bedit_simulation import ModelBuildResult, compile_component_sync + +Compiler = Callable[[Component, str | Path], ModelBuildResult] +Launcher = Callable[[Path, CompiledModel], bool] + + +def _launch_simulator(path: Path, compiled_model: CompiledModel) -> bool: + arguments = ["-m", "bedit_gui.simulation_application", "--handoff", "--model-name", compiled_model.model_name, "--executable", compiled_model.executable, "--working-directory", compiled_model.working_directory, str(path)] + launched = QProcess.startDetached(sys.executable, arguments) + return launched[0] if isinstance(launched, tuple) else bool(launched) + + +class SimulationController(QObject): + """Compile the active BEdit simulation and launch the simulator process.""" + + def __init__(self, document: Document, window: MainWindow, compiler: Compiler = compile_component_sync, launcher: Launcher = _launch_simulator) -> None: + super().__init__(window) + self.document = document + self.window = window + self.compiler = compiler + self.launcher = launcher + self.compiled_root: SimulationRoot | None = None + self.compiled_model: CompiledModel | None = None + window.ui.actionCompile_Model.triggered.connect(self.compile_model) + window.ui.actionOpen_Simulation_Window.triggered.connect(self.open_simulation) + + def compile_model(self) -> SimulationRoot | None: + database = self.document.simulation_database() + settings = database.simulations.get(database.active_simulation) if database.active_simulation is not None else None + if settings is None: + QMessageBox.warning(self.window, "No active simulation", "Select and accept a simulation settings block first.") + return None + match = next((choice for choice in component_choices(self.document.model) if choice[0] == settings.component), None) + if match is None: + QMessageBox.critical(self.window, "Could not compile", "The active simulation references a missing component.") + return None + component_id, component, component_path = match + build_directory = Path(tempfile.mkdtemp(prefix="bedit-compiled-")) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + self.window.statusBar().showMessage(f"Compiling {component_path}…") + try: + build = self.compiler(component, build_directory) + except (OSError, RuntimeError, ValueError) as exc: + QMessageBox.critical(self.window, "Could not compile", str(exc)) + self.window.statusBar().showMessage("Compilation failed") + return None + finally: + QApplication.restoreOverrideCursor() + self.compiled_model = CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve())) + self.compiled_root = SimulationRoot( + format_version=1, + source_document=str(self.document.path.resolve()) if self.document.path is not None else None, + source_document_id=str(self.document.model.id), + component=component_id, + component_path=component_path, + settings_name=settings.name, + settings=settings, + ) + self.window.statusBar().showMessage(f"Compiled {component_path}") + return self.compiled_root + + def open_simulation(self) -> None: + root = self.compile_model() + if root is None or self.compiled_model is None: + return + transfer_directory = Path(tempfile.mkdtemp(prefix="bedit-simulator-launch-")) + transfer_path = transfer_directory / "simulation.bes" + try: + simulation_files.save(root, transfer_path) + if not self.launcher(transfer_path, self.compiled_model): + raise RuntimeError("the simulator process could not be started") + except (OSError, RuntimeError, ValueError) as exc: + QMessageBox.critical(self.window, "Could not open simulator", str(exc)) diff --git a/src/bedit_gui/controllers/simulation_file_controller.py b/src/bedit_gui/controllers/simulation_file_controller.py new file mode 100644 index 0000000..7b03b31 --- /dev/null +++ b/src/bedit_gui/controllers/simulation_file_controller.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from pathlib import Path + +from PySide6.QtCore import QObject, Qt +from PySide6.QtWidgets import QApplication, QFileDialog, QInputDialog, QMessageBox + +from bedit_gui.services import document_files, simulation_files +from bedit_gui.services.simulation_loader import compile_simulation_root, component_choices, load_and_compile_bedit +from bedit_gui.simulation_models import CompiledModel, SimulationRoot +from bedit_gui.views.simulation_window import SimulationWindow + + +class SimulationFileController(QObject): + def __init__(self, window: SimulationWindow) -> None: + super().__init__(window) + self.window = window + self.root: SimulationRoot | None = None + self.compiled_model: CompiledModel | None = None + self.path: Path | None = None + window.ui.actionNew_Simulation_Run.triggered.connect(self.new) + window.ui.actionOpen_Simulation_Run.triggered.connect(self.open_dialog) + window.ui.actionSave_Simulation_Run.triggered.connect(self.save) + self._update_window() + + def new(self) -> None: + self.root = None + self.compiled_model = None + self.path = None + self._update_window() + + def open(self, path: str | Path, *, component: str | None = None, simulation: str | None = None, backed_by_file: bool = True, compiled_model: CompiledModel | None = None) -> None: + file_path = Path(path) + if file_path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(file_path): + self.root = simulation_files.load(file_path) + self.compiled_model = compiled_model or self._recompile_with_wait_cursor(self.root) + self.path = file_path if backed_by_file else None + else: + self.root, self.compiled_model = self._compile_with_wait_cursor(file_path, component=component, simulation=simulation) + self.path = None + self._update_window() + + def open_dialog(self) -> None: + filename, _ = QFileDialog.getOpenFileName(self.window, "Open Simulation", "", "Simulation and BEdit files (*.bes *.beb *.json)") + if not filename: + return + try: + path = Path(filename) + if path.suffix.lower() == ".bes" or simulation_files.is_simulation_json(path): + self.open(path) + return + document = document_files.load(path) + database = document.metadata.get("simulation_database") if document.metadata is not None else None + entries = [(f"Simulation: {simulation.name}", None, simulation.name) for simulation in getattr(database, "simulations", {}).values()] + entries.extend((f"Component: {component_path}", component_path, None) for _component_id, _component, component_path in component_choices(document)) + if not entries: + raise ValueError("the BEdit document contains no components or simulation settings") + label, accepted = QInputDialog.getItem(self.window, "Simulation Target", "Compile:", [entry[0] for entry in entries], 0, False) + if accepted: + _display, component, simulation = next(entry for entry in entries if entry[0] == label) + self.open(path, component=component, simulation=simulation) + except (OSError, RuntimeError, ValueError) as exc: + QMessageBox.critical(self.window, "Could not open simulation", str(exc)) + + def save(self) -> None: + if self.root is None: + return + path = self.path + if path is None: + filename, _ = QFileDialog.getSaveFileName(self.window, "Save Simulation", "simulation.bes", "BEdit simulation (*.bes);;Simulation JSON (*.json)") + if not filename: + return + path = Path(filename) + if path.suffix.lower() not in (".bes", ".json"): + path = path.with_suffix(".bes") + try: + simulation_files.save(self.root, path) + except (OSError, ValueError) as exc: + QMessageBox.critical(self.window, "Could not save simulation", str(exc)) + return + self.path = path + self._update_window() + + def _compile_with_wait_cursor(self, path: Path, *, component: str | None, simulation: str | None) -> tuple[SimulationRoot, CompiledModel]: + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + try: + return load_and_compile_bedit(path, component_selector=component, simulation_selector=simulation) + finally: + QApplication.restoreOverrideCursor() + + def _recompile_with_wait_cursor(self, root: SimulationRoot) -> CompiledModel: + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + try: + return compile_simulation_root(root) + finally: + QApplication.restoreOverrideCursor() + + def _update_window(self) -> None: + if self.root is None: + self.window.setWindowTitle("BEdit Simulator") + self.window.statusBar().showMessage("No simulation loaded") + self.window.ui.actionSave_Simulation_Run.setEnabled(False) + return + self.window.setWindowTitle(f"{self.root.settings.name} — BEdit Simulator") + compiled = self.compiled_model.executable if self.compiled_model is not None else "not compiled" + self.window.statusBar().showMessage(f"{self.root.component_path} · {compiled}") + self.window.ui.actionSave_Simulation_Run.setEnabled(True) diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py index 1669a73..152e494 100644 --- a/src/bedit_gui/documents/document.py +++ b/src/bedit_gui/documents/document.py @@ -16,7 +16,7 @@ from bedit_gui.commands.rename_component_command import RenameComponentCommand from bedit_gui.commands.rename_document_command import RenameDocumentCommand from bedit_gui.commands.simulation_database_command import ChangeSimulationDatabaseCommand from bedit_gui.commands.component_command import AddEmptyEquationComponent, AddEmptyGraphComponent, DeleteComponent, PasteComponents -from bedit_gui.models import Icon, IconDatabase, SimulationDatabase +from bedit_gui.models import Icon, IconDatabase, Simulation, SimulationDatabase from bedit_gui.services import document_files @@ -131,6 +131,10 @@ class Document(QObject): def simulation_database(self) -> SimulationDatabase: return self.stored_simulation_database() or SimulationDatabase() + def active_simulation(self) -> Simulation | None: + database = self.simulation_database() + return database.simulations.get(database.active_simulation) if database.active_simulation is not None else None + def change_simulation_database(self, database: SimulationDatabase) -> None: if database != self.stored_simulation_database(): self.undo_stack.push(ChangeSimulationDatabaseCommand(self, database)) diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py index ed43028..1a4fa0f 100644 --- a/src/bedit_gui/models.py +++ b/src/bedit_gui/models.py @@ -176,11 +176,14 @@ class Simulation: class SimulationDatabase: format_version: int = 1 simulations: dict[SimulationID, Simulation] = field(default_factory=dict) + active_simulation: SimulationID | None = None @classmethod def from_data(cls, data: Mapping[str, Any]) -> SimulationDatabase: sims = {SimulationID(key): Simulation.from_data(value) for key, value in data.get("simulations", {}).items()} - return cls(format_version=int(data.get("format_version", 1)), simulations=sims) + active = data.get("active_simulation") + active_id = SimulationID(str(active)) if active is not None else None + return cls(format_version=int(data.get("format_version", 1)), simulations=sims, active_simulation=active_id if active_id in sims else None) def to_data(self) -> dict[str, Any]: - return {"format_version": self.format_version, "simulations": {str(key): sim.to_data() for key, sim in self.simulations.items()}} + return {"format_version": self.format_version, "active_simulation": str(self.active_simulation) if self.active_simulation is not None else None, "simulations": {str(key): sim.to_data() for key, sim in self.simulations.items()}} diff --git a/src/bedit_gui/services/simulation_files.py b/src/bedit_gui/services/simulation_files.py new file mode 100644 index 0000000..91ca959 --- /dev/null +++ b/src/bedit_gui/services/simulation_files.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +import zlib +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import msgpack + +from bedit_gui.simulation_models import SimulationRoot + +BES_MAGIC = b"BES\x00" +FILE_FORMAT_VERSION = 1 +_VERSION_SIZE = 4 + + +def load(path: str | Path) -> SimulationRoot: + file_path = Path(path) + data = _load_json(file_path) if file_path.suffix.lower() == ".json" else _load_bes(file_path) + return SimulationRoot.from_data(data) + + +def save(root: SimulationRoot, path: str | Path) -> None: + file_path = Path(path) + if file_path.suffix.lower() == ".json": + _save_json(root.to_data(), file_path) + elif file_path.suffix.lower() == ".bes": + _save_bes(root.to_data(), file_path) + else: + raise ValueError(f"unsupported simulation file extension {file_path.suffix!r}; expected '.json' or '.bes'") + + +def is_simulation_json(path: str | Path) -> bool: + file_path = Path(path) + if file_path.suffix.lower() != ".json": + return False + try: + return _load_json(file_path).get("root_type") == "simulation_root" + except (TypeError, ValueError): + return False + + +def _load_json(path: Path) -> Mapping[str, Any]: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ValueError(f"could not read simulation JSON {path}: {exc}") from exc + if not isinstance(data, Mapping): + raise TypeError(f"simulation JSON {path} must contain an object") + return data + + +def _save_json(data: Mapping[str, Any], path: Path) -> None: + path.write_text(json.dumps({"file_format_version": FILE_FORMAT_VERSION, **data}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + +def _load_bes(path: Path) -> Mapping[str, Any]: + try: + payload = path.read_bytes() + header_end = len(BES_MAGIC) + _VERSION_SIZE + if not payload.startswith(BES_MAGIC): + raise ValueError("missing BES file header") + if len(payload) < header_end: + raise ValueError("truncated BES file header") + version = int.from_bytes(payload[len(BES_MAGIC):header_end], "big") + if version != FILE_FORMAT_VERSION: + raise ValueError(f"unsupported BES file version {version}") + data = msgpack.unpackb(zlib.decompress(payload[header_end:]), raw=False, strict_map_key=False) + except (OSError, ValueError, zlib.error, msgpack.exceptions.MsgpackException) as exc: + raise ValueError(f"could not read BES simulation {path}: {exc}") from exc + if not isinstance(data, Mapping): + raise TypeError(f"BES simulation {path} must contain a map") + return data + + +def _save_bes(data: Mapping[str, Any], path: Path) -> None: + encoded = zlib.compress(msgpack.packb(dict(data), use_bin_type=True)) + path.write_bytes(BES_MAGIC + FILE_FORMAT_VERSION.to_bytes(_VERSION_SIZE, "big") + encoded) diff --git a/src/bedit_gui/services/simulation_loader.py b/src/bedit_gui/services/simulation_loader.py new file mode 100644 index 0000000..218952e --- /dev/null +++ b/src/bedit_gui/services/simulation_loader.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import tempfile +from pathlib import Path + +from bedit_core.models import Component, ComponentID, Document, GraphImplementation +from bedit_gui.models import Simulation, SimulationMethod +from bedit_gui.services import document_files +from bedit_gui.simulation_models import CompiledModel, SimulationRoot +from bedit_simulation import compile_component_sync + + +def component_choices(document: Document) -> list[tuple[ComponentID, Component, str]]: + choices: list[tuple[ComponentID, Component, str]] = [] + + def collect(items: dict[ComponentID, Component], path: tuple[str, ...] = ()) -> None: + for component_id, component in items.items(): + component_path = (*path, component.name) + choices.append((component_id, component, ".".join(component_path))) + if isinstance(component.implementation, GraphImplementation): + collect(component.implementation.graph.components, component_path) + + collect(document.root) + return choices + + +def load_and_compile_bedit(path: str | Path, *, component_selector: str | None = None, simulation_selector: str | None = None, working_directory: str | Path | None = None, omc_command: str = "omc") -> tuple[SimulationRoot, CompiledModel]: + """Open a BEdit document, resolve one launch target, and compile it.""" + if bool(component_selector) == bool(simulation_selector): + raise ValueError("specify exactly one component or simulation settings block") + source_path = Path(path).resolve() + document = document_files.load(source_path) + choices = component_choices(document) + settings_name: str | None = None + + if component_selector is not None: + component_id, component, component_path = _find_component(choices, component_selector) + settings = _default_settings(component_id) + else: + database = document.metadata.get("simulation_database") if document.metadata is not None else None + if database is None or not hasattr(database, "simulations"): + raise ValueError("the BEdit document does not contain simulation settings") + matches = [(simulation_id, simulation) for simulation_id, simulation in database.simulations.items() if simulation.name == simulation_selector or str(simulation_id) == simulation_selector] + if len(matches) != 1: + raise ValueError(f"simulation settings {simulation_selector!r} were not found or are ambiguous") + _simulation_id, settings = matches[0] + component_id, component, component_path = _find_component(choices, str(settings.component)) + settings_name = settings.name + + build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-")) + root = SimulationRoot( + format_version=1, + source_document=str(source_path), + source_document_id=str(document.id), + component=component_id, + component_path=component_path, + settings_name=settings_name, + settings=settings, + ) + return root, _compile(component, build_directory, omc_command) + + +def compile_simulation_root(root: SimulationRoot, *, working_directory: str | Path | None = None, omc_command: str = "omc") -> CompiledModel: + if root.source_document is None: + raise ValueError("the simulation does not reference a BEdit source document and cannot be recompiled") + document = document_files.load(root.source_document) + _component_id, component, _component_path = _find_component(component_choices(document), str(root.component)) + build_directory = Path(working_directory) if working_directory is not None else Path(tempfile.mkdtemp(prefix="bedit-compiled-")) + return _compile(component, build_directory, omc_command) + + +def _find_component(choices: list[tuple[ComponentID, Component, str]], selector: str) -> tuple[ComponentID, Component, str]: + matches = [choice for choice in choices if str(choice[0]) == selector or choice[2] == selector] + if len(matches) != 1: + raise ValueError(f"component {selector!r} was not found or is ambiguous") + return matches[0] + + +def _default_settings(component_id: ComponentID) -> Simulation: + return Simulation( + component=component_id, + name="Default", + start_time=0.0, + duration=1.0, + use_timed_steps=False, + number_of_steps=500, + step_size=0.002, + method=SimulationMethod.DASSL, + dassl_tolerance=1e-6, + ) + + +def _compile(component: Component, working_directory: Path, omc_command: str) -> CompiledModel: + build = compile_component_sync(component, working_directory, omc_command=omc_command) + return CompiledModel(build.model_name, str(build.executable.resolve()), str(build.executable.parent.resolve())) diff --git a/src/bedit_gui/simulation_application.py b/src/bedit_gui/simulation_application.py index 3a5b218..d10d252 100644 --- a/src/bedit_gui/simulation_application.py +++ b/src/bedit_gui/simulation_application.py @@ -1,31 +1,49 @@ from __future__ import annotations -import sys import argparse +import sys -def parse_arguments(): - parser = argparse.ArgumentParser(exit_on_error=False) +from PySide6.QtWidgets import QApplication, QMessageBox - parser.add_argument( - "-f", "--file", type=str, help="Path to a BEditor BEsim file to open", default=None, required=False - ) - parser.add_argument( - "-c", "--component", type=str, help="Component path in BEdit file to simulate", default=None, required=False - ) - parser.add_argument( - "-s", "--simulation", type=str, help="Simulation name in BEdit file to simulate", default=None, required=False - ) +from bedit_gui.controllers.simulation_file_controller import SimulationFileController +from bedit_gui.simulation_models import CompiledModel +from bedit_gui.views.simulation_window import SimulationWindow - return parser.parse_args(), parser -def main() -> int: - try: - args, parser = parse_arguments() - # TODO check if file is a BEditor file (.beb or .json) - if args.file and not (args.component or args.simulation): - parser.error("Component or Simulation required when opening a BEdit file") - except argparse.ArgumentError as e: - print(e.message) - return 1 +def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Open and run BEdit simulations") + parser.add_argument("file", nargs="?", help="BEdit (.beb/.json) or simulation (.bes/.json) file") + parser.add_argument("-f", "--file", dest="file_option", help=argparse.SUPPRESS) + parser.add_argument("--handoff", action="store_true", help=argparse.SUPPRESS) + parser.add_argument("--model-name", help=argparse.SUPPRESS) + parser.add_argument("--executable", help=argparse.SUPPRESS) + parser.add_argument("--working-directory", help=argparse.SUPPRESS) + target = parser.add_mutually_exclusive_group() + target.add_argument("-c", "--component", help="Component ID or dotted path in a BEdit file") + target.add_argument("-s", "--simulation", help="Simulation settings name or ID in a BEdit file") + args = parser.parse_args(arguments) + args.file = args.file_option or args.file + if args.handoff and not all((args.model_name, args.executable, args.working_directory)): + parser.error("a simulator handoff requires compiled-model arguments") + return args - return 0 \ No newline at end of file + +def main(arguments: list[str] | None = None) -> int: + args = parse_arguments(arguments) + app = QApplication(sys.argv if arguments is None else [sys.argv[0], *arguments]) + app.setOrganizationName("BEdit") + app.setApplicationName("BEdit Simulator") + window = SimulationWindow() + controller = SimulationFileController(window) + window.showMaximized() + if args.file: + try: + compiled_model = CompiledModel(args.model_name, args.executable, args.working_directory) if args.handoff else None + controller.open(args.file, component=args.component, simulation=args.simulation, backed_by_file=not args.handoff, compiled_model=compiled_model) + except (OSError, ValueError, RuntimeError) as exc: + QMessageBox.critical(window, "Could not open simulation", str(exc)) + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/bedit_gui/simulation_models.py b/src/bedit_gui/simulation_models.py index 6a95d99..d40e883 100644 --- a/src/bedit_gui/simulation_models.py +++ b/src/bedit_gui/simulation_models.py @@ -1,11 +1,59 @@ from __future__ import annotations from collections.abc import Mapping -from dataclasses import dataclass, field -from enum import Enum +from dataclasses import dataclass from typing import Any +from bedit_core.models import ComponentID +from bedit_gui.models import Simulation + + +@dataclass +class CompiledModel: + model_name: str + executable: str + working_directory: str + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> CompiledModel: + return cls(model_name=str(data["model_name"]), executable=str(data["executable"]), working_directory=str(data["working_directory"])) + + def to_data(self) -> dict[str, Any]: + return {"model_name": self.model_name, "executable": self.executable, "working_directory": self.working_directory} + + @dataclass class SimulationRoot: - format_version: int = 1 - model_file_path: str | None # The file path of the BEdit file if there is one \ No newline at end of file + format_version: int + source_document: str | None + source_document_id: str | None + component: ComponentID + component_path: str + settings_name: str | None + settings: Simulation + + @classmethod + def from_data(cls, data: Mapping[str, Any]) -> SimulationRoot: + if data.get("root_type") != "simulation_root": + raise ValueError("file does not contain a simulation root") + return cls( + format_version=int(data.get("format_version", 1)), + source_document=str(data["source_document"]) if data.get("source_document") is not None else None, + source_document_id=str(data["source_document_id"]) if data.get("source_document_id") is not None else None, + component=ComponentID(str(data["component"])), + component_path=str(data.get("component_path", "")), + settings_name=str(data["settings_name"]) if data.get("settings_name") is not None else None, + settings=Simulation.from_data(data["settings"]), + ) + + def to_data(self) -> dict[str, Any]: + return { + "root_type": "simulation_root", + "format_version": self.format_version, + "source_document": self.source_document, + "source_document_id": self.source_document_id, + "component": str(self.component), + "component_path": self.component_path, + "settings_name": self.settings_name, + "settings": self.settings.to_data(), + } diff --git a/src/bedit_gui/views/dialogs/simulation_settings_dialog.py b/src/bedit_gui/views/dialogs/simulation_settings_dialog.py index 8b51942..a2d8d24 100644 --- a/src/bedit_gui/views/dialogs/simulation_settings_dialog.py +++ b/src/bedit_gui/views/dialogs/simulation_settings_dialog.py @@ -51,7 +51,7 @@ class SimulationSettingsDialog(QDialog): self.ui.simulationMethodComboBox.currentIndexChanged.connect(self._form_changed) self.ui.toleranceEdit.textEdited.connect(self._form_changed) - self._rebuild_list() + self._rebuild_list(self._database.active_simulation) def database(self) -> SimulationDatabase: return deepcopy(self._database) @@ -68,6 +68,7 @@ class SimulationSettingsDialog(QDialog): QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.") return simulation.name = simulation.name.strip() + self._database.active_simulation = self._simulation_id() super().accept() def _rebuild_list(self, selected_id: SimulationID | None = None) -> None: @@ -172,6 +173,8 @@ class SimulationSettingsDialog(QDialog): row = self._simulation_ids.index(simulation_id) del self._database.simulations[simulation_id] self._simulation_ids.remove(simulation_id) + if self._database.active_simulation == simulation_id: + self._database.active_simulation = None selected = self._simulation_ids[min(row, len(self._simulation_ids) - 1)] if self._simulation_ids else None self._rebuild_list(selected) diff --git a/src/bedit_gui/views/simulation_window.py b/src/bedit_gui/views/simulation_window.py new file mode 100644 index 0000000..2528ffa --- /dev/null +++ b/src/bedit_gui/views/simulation_window.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from PySide6.QtWidgets import QMainWindow + +from bedit_gui.ui.generated.ui_simulation_window import Ui_SimulationWindow + + +class SimulationWindow(QMainWindow): + def __init__(self) -> None: + super().__init__() + self.ui = Ui_SimulationWindow() + self.ui.setupUi(self) + self.setWindowTitle("BEdit Simulator") diff --git a/src/bedit_simulation/__init__.py b/src/bedit_simulation/__init__.py index cc4f7da..6d4999d 100644 --- a/src/bedit_simulation/__init__.py +++ b/src/bedit_simulation/__init__.py @@ -6,6 +6,7 @@ from .openmodelica import ( ProcessResult, ) from .results import SimulationResult, load_openmodelica_csv +from .compile import compile_component, compile_component_sync from .simulation import ( ModelBuildResult, ModelCheckResult, @@ -18,17 +19,19 @@ from .simulation import ( ) __all__ = [ - "OpenModelicaError", - "OpenModelicaRunner", - "ProcessResult", "ModelBuildResult", "ModelCheckResult", "ModelInfo", + "OpenModelicaError", + "OpenModelicaRunner", + "ProcessResult", "Simulation", "SimulationOptions", "SimulationProgress", - "SimulationStateError", "SimulationResult", + "SimulationStateError", + "compile_component", + "compile_component_sync", "load_openmodelica_csv", "simulate", ] diff --git a/src/bedit_simulation/compile.py b/src/bedit_simulation/compile.py new file mode 100644 index 0000000..052c77b --- /dev/null +++ b/src/bedit_simulation/compile.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +from bedit_core.models import Component + +from .openmodelica import OpenModelicaRunner +from .simulation import ModelBuildResult, Simulation + + +async def compile_component(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult: + """Compose and compile a component without creating a GUI application.""" + simulation = Simulation(OpenModelicaRunner(omc_command, timeout=timeout)) + await simulation.load(component) + return await simulation.compile(working_directory) + + +def compile_component_sync(component: Component, working_directory: str | Path, *, omc_command: str = "omc", timeout: float | None = None) -> ModelBuildResult: + """Synchronous entry point for scripts and non-async suite applications.""" + return asyncio.run(compile_component(component, working_directory, omc_command=omc_command, timeout=timeout)) diff --git a/untitled.bedit.json b/untitled.bedit.json index b212d60..e42d532 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -591,6 +591,7 @@ }, "simulation_database": { "format_version": 1, + "active_simulation": "7778ca68-7a36-401b-b4aa-236a44c5e771", "simulations": { "7778ca68-7a36-401b-b4aa-236a44c5e771": { "component": "50e6ef97-f686-4400-bc01-e5a352e8cc22",