diff --git a/src/bedit_gui/application.py b/src/bedit_gui/application.py index 98c7b63..f2be61c 100644 --- a/src/bedit_gui/application.py +++ b/src/bedit_gui/application.py @@ -9,6 +9,7 @@ from bedit_gui.controllers.clipboard_controller import ClipboardController, Docu 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.undo_controller import UndoController from bedit_gui.controllers.view_menu_controller import ViewMenuController from bedit_gui.controllers.window_state_controller import WindowStateController @@ -46,6 +47,7 @@ def main() -> int: LogController(window, settings.log_level) DocumentController(document, window) SettingsController(window, settings) + SimulationSettingsController(document, window) UndoController(document, window) ViewMenuController(window) document_tree_controller = DocumentTreeController(document, window) diff --git a/src/bedit_gui/commands/simulation_database_command.py b/src/bedit_gui/commands/simulation_database_command.py new file mode 100644 index 0000000..0f565e4 --- /dev/null +++ b/src/bedit_gui/commands/simulation_database_command.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from copy import deepcopy + +from PySide6.QtGui import QUndoCommand + +from bedit_gui.models import SimulationDatabase + + +class ChangeSimulationDatabaseCommand(QUndoCommand): + def __init__(self, document: object, database: SimulationDatabase) -> None: + super().__init__("Edit simulation settings") + self.document = document + self.old_database = document.stored_simulation_database() + self.new_database = deepcopy(database) + + def redo(self) -> None: + self.document._set_simulation_database(self.new_database) + + def undo(self) -> None: + self.document._set_simulation_database(self.old_database) diff --git a/src/bedit_gui/controllers/simulation_settings_controller.py b/src/bedit_gui/controllers/simulation_settings_controller.py new file mode 100644 index 0000000..be5f026 --- /dev/null +++ b/src/bedit_gui/controllers/simulation_settings_controller.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Protocol + +from PySide6.QtCore import QObject +from PySide6.QtWidgets import QDialog + +from bedit_core.models import Component, ComponentID, GraphImplementation +from bedit_gui.documents import Document +from bedit_gui.models import SimulationDatabase +from bedit_gui.views.dialogs.simulation_settings_dialog import SimulationSettingsDialog +from bedit_gui.views.main_window import MainWindow + + +class SimulationSettingsDialogLike(Protocol): + def exec(self) -> int: ... + def database(self) -> SimulationDatabase: ... + + +SimulationSettingsDialogFactory = Callable[[SimulationDatabase, list[tuple[ComponentID, str]], MainWindow], SimulationSettingsDialogLike] + + +class SimulationSettingsController(QObject): + def __init__(self, document: Document, window: MainWindow, dialog_factory: SimulationSettingsDialogFactory = SimulationSettingsDialog) -> None: + super().__init__(window) + self.document = document + self.window = window + self.dialog_factory = dialog_factory + window.ui.actionSimulation_Settings.triggered.connect(self.open_settings) + + def open_settings(self) -> None: + dialog = self.dialog_factory(self.document.simulation_database(), self._components(), self.window) + if dialog.exec() == QDialog.DialogCode.Accepted: + self.document.change_simulation_database(dialog.database()) + + def _components(self) -> list[tuple[ComponentID, str]]: + components: list[tuple[ComponentID, str]] = [] + + def collect(items: dict[ComponentID, Component], path: tuple[str, ...] = ()) -> None: + for component_id, component in items.items(): + component_path = (*path, component.name) + components.append((component_id, ".".join(component_path))) + if isinstance(component.implementation, GraphImplementation): + collect(component.implementation.graph.components, component_path) + + collect(self.document.model.root) + return components diff --git a/src/bedit_gui/documents/document.py b/src/bedit_gui/documents/document.py index 2cfd3a9..1669a73 100644 --- a/src/bedit_gui/documents/document.py +++ b/src/bedit_gui/documents/document.py @@ -14,8 +14,9 @@ from bedit_gui.commands.port_commands import AddPortCommand, ChangePortCommand, from bedit_gui.commands.param_commands import AddParamCommand, ChangeParamCommand, RemoveParamCommand 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 +from bedit_gui.models import Icon, IconDatabase, SimulationDatabase from bedit_gui.services import document_files @@ -27,6 +28,7 @@ class Document(QObject): modified_changed = Signal(bool) icon_changed = Signal(object, object) equation_text_changed = Signal(object, str) + simulation_database_changed = Signal(object) def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) @@ -122,6 +124,44 @@ class Document(QObject): def change_icon(self, component_id: ComponentID, icon: Icon) -> None: self.undo_stack.push(ChangeIconCommand(self, component_id, icon)) + def stored_simulation_database(self) -> SimulationDatabase | None: + database = self._simulation_database(False) + return deepcopy(database) if database is not None else None + + def simulation_database(self) -> SimulationDatabase: + return self.stored_simulation_database() or SimulationDatabase() + + def change_simulation_database(self, database: SimulationDatabase) -> None: + if database != self.stored_simulation_database(): + self.undo_stack.push(ChangeSimulationDatabaseCommand(self, database)) + + def _set_simulation_database(self, database: SimulationDatabase | None) -> None: + if database is None: + if self.model.metadata is not None: + self.model.metadata.pop("simulation_database", None) + else: + if self.model.metadata is None: + self.model.metadata = {} + self.model.metadata["simulation_database"] = deepcopy(database) + self.simulation_database_changed.emit(self.stored_simulation_database()) + + def _simulation_database(self, create: bool) -> SimulationDatabase | None: + metadata = self.model.metadata + value = metadata.get("simulation_database") if metadata is not None else None + if isinstance(value, dict): + value = SimulationDatabase.from_data(value) + metadata["simulation_database"] = value + if isinstance(value, SimulationDatabase): + return value + if not create: + return None + if metadata is None: + metadata = {} + self.model.metadata = metadata + database = SimulationDatabase() + metadata["simulation_database"] = database + return database + def _set_component_icon(self, component_id: ComponentID, icon: Icon | None) -> None: if icon is None: database = self._icon_database(False) diff --git a/src/bedit_gui/models.py b/src/bedit_gui/models.py index eac6e22..ed43028 100644 --- a/src/bedit_gui/models.py +++ b/src/bedit_gui/models.py @@ -143,33 +143,38 @@ class Simulation: @classmethod def from_data(cls, data: Mapping[str, Any]) -> Simulation: + component = data.get("component") + use_timed_steps = data.get("use_timed_steps", False) + if isinstance(use_timed_steps, str): + use_timed_steps = use_timed_steps.lower() == "true" return cls( - component=SimulationID(data.get("component"), SimulationID()), - name=data.get("name", ""), - start_time=float(data.get("start_time", "0.0")), - duration=float(data.get("duration", "1.0")), - use_timed_steps=bool(data.get("use_timed_steps", "false")), - number_of_steps=int(data.get("number_of_steps", "500")), - step_size=float(data.get("step_size", "0.001")), + component=ComponentID(str(component)) if component else ComponentID(), + name=str(data.get("name", "")), + start_time=float(data.get("start_time", 0.0)), + duration=float(data.get("duration", 1.0)), + use_timed_steps=bool(use_timed_steps), + number_of_steps=int(data.get("number_of_steps", 500)), + step_size=float(data.get("step_size", 0.001)), method=SimulationMethod(data.get("method", "dassl")), - dassl_tolerance=float(data.get("dassl_tolerance", "1e-6")), + dassl_tolerance=float(data.get("dassl_tolerance", 1e-6)), ) + def to_data(self) -> dict[str, Any]: return { "component": str(self.component), "name": self.name, - "start_time": str(self.start_time), - "duration": str(self.duration), - "use_timed_steps": str(self.use_timed_steps), - "number_of_steps": str(self.number_of_steps), - "step_size": str(self.step_size), - "method": str(self.method), - "dassl_tolerance": str(self.dassl_tolerance), + "start_time": self.start_time, + "duration": self.duration, + "use_timed_steps": self.use_timed_steps, + "number_of_steps": self.number_of_steps, + "step_size": self.step_size, + "method": self.method.value, + "dassl_tolerance": self.dassl_tolerance, } @dataclass class SimulationDatabase: - format_versio: int = 1 + format_version: int = 1 simulations: dict[SimulationID, Simulation] = field(default_factory=dict) @classmethod @@ -179,4 +184,3 @@ class SimulationDatabase: 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()}} - \ No newline at end of file diff --git a/src/bedit_gui/services/document_files.py b/src/bedit_gui/services/document_files.py index 20084e7..ded0ce1 100644 --- a/src/bedit_gui/services/document_files.py +++ b/src/bedit_gui/services/document_files.py @@ -6,7 +6,7 @@ from pathlib import Path from bedit_core.models import Document from bedit_core.serialization import load as load_document from bedit_core.serialization import save as save_document -from bedit_gui.models import IconDatabase +from bedit_gui.models import IconDatabase, SimulationDatabase def load(path: str | Path) -> Document: @@ -14,6 +14,8 @@ def load(path: str | Path) -> Document: document = load_document(path) if document.metadata is not None and isinstance(document.metadata.get("icon_database"), dict): document.metadata["icon_database"] = IconDatabase.from_data(document.metadata["icon_database"]) + if document.metadata is not None and isinstance(document.metadata.get("simulation_database"), dict): + document.metadata["simulation_database"] = SimulationDatabase.from_data(document.metadata["simulation_database"]) return document @@ -22,4 +24,6 @@ def save(document: Document, path: str | Path) -> None: saved_document = deepcopy(document) if saved_document.metadata is not None and isinstance(saved_document.metadata.get("icon_database"), IconDatabase): saved_document.metadata["icon_database"] = saved_document.metadata["icon_database"].to_data() + if saved_document.metadata is not None and isinstance(saved_document.metadata.get("simulation_database"), SimulationDatabase): + saved_document.metadata["simulation_database"] = saved_document.metadata["simulation_database"].to_data() save_document(saved_document, path) diff --git a/src/bedit_gui/ui/forms/simulation_settings.ui b/src/bedit_gui/ui/forms/simulation_settings.ui index f460840..2c486f5 100644 --- a/src/bedit_gui/ui/forms/simulation_settings.ui +++ b/src/bedit_gui/ui/forms/simulation_settings.ui @@ -17,7 +17,29 @@ - + + + + + + + + + + Add + + + + + + + Remove + + + + + + diff --git a/src/bedit_gui/views/dialogs/simulation_settings_dialog.py b/src/bedit_gui/views/dialogs/simulation_settings_dialog.py new file mode 100644 index 0000000..8b51942 --- /dev/null +++ b/src/bedit_gui/views/dialogs/simulation_settings_dialog.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +from copy import deepcopy + +from PySide6.QtCore import Qt +from PySide6.QtGui import QDoubleValidator +from PySide6.QtWidgets import QDialog, QListWidgetItem, QMessageBox, QWidget + +from bedit_core.models import ComponentID +from bedit_gui.models import Simulation, SimulationDatabase, SimulationID, SimulationMethod +from bedit_gui.ui.generated.ui_simulation_settings import Ui_Dialog + + +class SimulationSettingsDialog(QDialog): + """Editor for the detached simulation database of a document.""" + + def __init__(self, database: SimulationDatabase, components: list[tuple[ComponentID, str]], parent: QWidget | None = None) -> None: + super().__init__(parent) + + self.ui = Ui_Dialog() + self.ui.setupUi(self) + self.setWindowTitle("Simulation Settings") + self._database = deepcopy(database) + self._simulation_ids = list(self._database.simulations) + self._components = components + self._loading = False + + self.ui.startTimeSpinBox.setRange(-1e12, 1e12) + self.ui.simLengthSpinBox.setRange(0.0, 1e12) + self.ui.stepSizeSpinBox.setRange(1e-9, 1e12) + self.ui.nrOfStepsSpinBox.setRange(1, 1_000_000_000) + tolerance_validator = QDoubleValidator(0.0, 1e12, 16, self) + tolerance_validator.setNotation(QDoubleValidator.Notation.ScientificNotation) + self.ui.toleranceEdit.setValidator(tolerance_validator) + for component_id, path in components: + self.ui.componentCompoBox.addItem(path, str(component_id)) + for method in SimulationMethod: + self.ui.simulationMethodComboBox.addItem(method.value.upper(), method.value) + + self.ui.simulationList.currentRowChanged.connect(self._selection_changed) + self.ui.addSimulationButton.clicked.connect(self._add_simulation) + self.ui.removeSimulationButton.clicked.connect(self._remove_simulation) + self.ui.nameEdit.textEdited.connect(self._form_changed) + self.ui.componentCompoBox.currentIndexChanged.connect(self._form_changed) + self.ui.startTimeSpinBox.valueChanged.connect(self._form_changed) + self.ui.simLengthSpinBox.valueChanged.connect(self._form_changed) + self.ui.stepSizeButton.toggled.connect(self._form_changed) + self.ui.nrOfStepsButton.toggled.connect(self._form_changed) + self.ui.stepSizeSpinBox.valueChanged.connect(self._form_changed) + self.ui.nrOfStepsSpinBox.valueChanged.connect(self._form_changed) + self.ui.simulationMethodComboBox.currentIndexChanged.connect(self._form_changed) + self.ui.toleranceEdit.textEdited.connect(self._form_changed) + + self._rebuild_list() + + def database(self) -> SimulationDatabase: + return deepcopy(self._database) + + def accept(self) -> None: + for simulation in self._database.simulations.values(): + if not simulation.name.strip(): + QMessageBox.warning(self, "Invalid simulation", "Every simulation must have a name.") + return + if simulation.component not in {component_id for component_id, _ in self._components}: + QMessageBox.warning(self, "Invalid simulation", f"Select an existing component for {simulation.name}.") + return + if simulation.dassl_tolerance <= 0: + QMessageBox.warning(self, "Invalid simulation", "The DASSL tolerance must be greater than zero.") + return + simulation.name = simulation.name.strip() + super().accept() + + def _rebuild_list(self, selected_id: SimulationID | None = None) -> None: + self.ui.simulationList.clear() + for simulation_id in self._simulation_ids: + item = QListWidgetItem(self._database.simulations[simulation_id].name) + item.setData(Qt.ItemDataRole.UserRole, simulation_id) + self.ui.simulationList.addItem(item) + if self._simulation_ids: + if selected_id not in self._database.simulations: + selected_id = self._simulation_ids[0] + self.ui.simulationList.setCurrentRow(self._simulation_ids.index(selected_id)) + else: + self._set_editor_enabled(False) + + def _selection_changed(self, row: int) -> None: + simulation_id = self._simulation_id(row) + self._set_editor_enabled(simulation_id is not None) + self.ui.removeSimulationButton.setEnabled(simulation_id is not None) + if simulation_id is not None: + self._load_simulation(self._database.simulations[simulation_id]) + + def _simulation_id(self, row: int | None = None) -> SimulationID | None: + if row is None: + row = self.ui.simulationList.currentRow() + if row < 0 or row >= len(self._simulation_ids): + return None + return self._simulation_ids[row] + + def _load_simulation(self, simulation: Simulation) -> None: + self._loading = True + self.ui.nameEdit.setText(simulation.name) + component_index = self.ui.componentCompoBox.findData(str(simulation.component)) + if component_index < 0: + self.ui.componentCompoBox.addItem(f"Missing component ({simulation.component})", str(simulation.component)) + component_index = self.ui.componentCompoBox.count() - 1 + self.ui.componentCompoBox.setCurrentIndex(component_index) + self.ui.startTimeSpinBox.setValue(simulation.start_time) + self.ui.simLengthSpinBox.setValue(simulation.duration) + self.ui.stepSizeButton.setChecked(simulation.use_timed_steps) + self.ui.nrOfStepsButton.setChecked(not simulation.use_timed_steps) + self.ui.stepSizeSpinBox.setValue(simulation.step_size) + self.ui.nrOfStepsSpinBox.setValue(simulation.number_of_steps) + self.ui.simulationMethodComboBox.setCurrentIndex(self.ui.simulationMethodComboBox.findData(simulation.method.value)) + self.ui.toleranceEdit.setText(str(simulation.dassl_tolerance)) + self._loading = False + self._update_step_inputs() + + def _form_changed(self, *_args: object) -> None: + if self._loading: + return + simulation_id = self._simulation_id() + if simulation_id is None: + return + + self._update_step_inputs() + simulation = self._database.simulations[simulation_id] + try: + tolerance = float(self.ui.toleranceEdit.text()) + except ValueError: + tolerance = simulation.dassl_tolerance + component_data = self.ui.componentCompoBox.currentData() + component = ComponentID(component_data) if isinstance(component_data, str) and component_data else simulation.component + method_data = self.ui.simulationMethodComboBox.currentData() + method = SimulationMethod(method_data) if isinstance(method_data, str) else simulation.method + self._database.simulations[simulation_id] = Simulation( + component=component, + name=self.ui.nameEdit.text(), + start_time=self.ui.startTimeSpinBox.value(), + duration=self.ui.simLengthSpinBox.value(), + use_timed_steps=self.ui.stepSizeButton.isChecked(), + number_of_steps=self.ui.nrOfStepsSpinBox.value(), + step_size=self.ui.stepSizeSpinBox.value(), + method=method, + dassl_tolerance=tolerance, + ) + self.ui.simulationList.item(self.ui.simulationList.currentRow()).setText(self.ui.nameEdit.text()) + + def _add_simulation(self) -> None: + if not self._components: + return + simulation_id = SimulationID() + simulation = Simulation( + component=self._components[0][0], + name=self._unique_name("Simulation"), + start_time=0.0, + duration=1.0, + use_timed_steps=False, + number_of_steps=500, + step_size=0.001, + method=SimulationMethod.DASSL, + dassl_tolerance=1e-6, + ) + self._database.simulations[simulation_id] = simulation + self._simulation_ids.append(simulation_id) + self._rebuild_list(simulation_id) + + def _remove_simulation(self) -> None: + simulation_id = self._simulation_id() + if simulation_id is None: + return + row = self._simulation_ids.index(simulation_id) + del self._database.simulations[simulation_id] + self._simulation_ids.remove(simulation_id) + selected = self._simulation_ids[min(row, len(self._simulation_ids) - 1)] if self._simulation_ids else None + self._rebuild_list(selected) + + def _unique_name(self, base: str) -> str: + names = {simulation.name for simulation in self._database.simulations.values()} + if base not in names: + return base + index = 2 + while f"{base} {index}" in names: + index += 1 + return f"{base} {index}" + + def _update_step_inputs(self) -> None: + timed = self.ui.stepSizeButton.isChecked() + self.ui.stepSizeSpinBox.setEnabled(timed) + self.ui.nrOfStepsSpinBox.setEnabled(not timed) + + def _set_editor_enabled(self, enabled: bool) -> None: + self.ui.frame.setEnabled(enabled) + self.ui.removeSimulationButton.setEnabled(enabled) + self.ui.addSimulationButton.setEnabled(bool(self._components)) diff --git a/untitled.bedit.json b/untitled.bedit.json index 562cdc7..b212d60 100644 --- a/untitled.bedit.json +++ b/untitled.bedit.json @@ -588,6 +588,22 @@ } } } + }, + "simulation_database": { + "format_version": 1, + "simulations": { + "7778ca68-7a36-401b-b4aa-236a44c5e771": { + "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 + } + } } } }