Simulation settings window

This commit is contained in:
2026-07-30 21:27:15 +02:00
parent a03c6d624e
commit b3aabae5e8
9 changed files with 372 additions and 20 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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()}}

View File

@@ -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)

View File

@@ -16,9 +16,31 @@
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<layout class="QVBoxLayout" name="simulationListLayout">
<item>
<widget class="QListWidget" name="simulationList"/>
</item>
<item>
<layout class="QHBoxLayout" name="simulationButtonLayout">
<item>
<widget class="QPushButton" name="addSimulationButton">
<property name="text">
<string>Add</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removeSimulationButton">
<property name="text">
<string>Remove</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</item>
<item>
<widget class="QFrame" name="frame">
<property name="frameShape">

View File

@@ -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))

View File

@@ -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
}
}
}
}
}