From c918a6a428efaeee3cb3300a2e0c49fa40320d19 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Wed, 22 Jul 2026 18:04:07 +0200 Subject: [PATCH] causality inference done --- BEdit/AGENTS.md | 3 +- BEdit/src/bedit/core/bond_graph.py | 239 +++++++-- BEdit/src/bedit/core/model.py | 14 +- BEdit/src/bedit/core/power_domains.py | 20 +- BEdit/src/bedit/core/simulation/service.py | 2 + BEdit/src/bedit/gui/controllers/document.py | 22 +- BEdit/src/bedit/gui/dialogs/port_options.py | 40 +- BEdit/src/bedit/gui/dialogs/settings.py | 16 + .../bedit/gui/generated/ui_settings_dialog.py | 44 +- .../bedit/gui/graphics/connection_styles.py | 2 +- BEdit/src/bedit/gui/graphics/workspace.py | 13 + BEdit/ui/settings_dialog.ui | 49 ++ BEdit/untitled.bedit.json | 506 +++++++++++++++++- 13 files changed, 911 insertions(+), 59 deletions(-) diff --git a/BEdit/AGENTS.md b/BEdit/AGENTS.md index 729b9db..ea56b6f 100644 --- a/BEdit/AGENTS.md +++ b/BEdit/AGENTS.md @@ -62,7 +62,8 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant - Port types are registered in `core/port_types.py`. Only compatible types may be connected. Signal ports connect by type; power ports additionally require the same domain. Editable power domains and causalities live in - `core/power_domains.py`. + `core/power_domains.py`. The `single effort in` and `single flow in` + causalities are available only on power ports that allow multiple connections. - Power ports may use `indifferent` orientation and can act as either connection endpoint. For two indifferent ports, click order determines source/arrow direction; otherwise output/input semantics determine direction. diff --git a/BEdit/src/bedit/core/bond_graph.py b/BEdit/src/bedit/core/bond_graph.py index 98fb7ab..7003319 100644 --- a/BEdit/src/bedit/core/bond_graph.py +++ b/BEdit/src/bedit/core/bond_graph.py @@ -37,16 +37,49 @@ def infer_causality(component: dict[str, Any], toplevel: bool = True) -> dict[st id_list = build_id_list(component) - # Check fixed causality - for connection in graph.get("connections", []): - if isinstance(connection, dict) and connection.get("type") == "power": - source, target = get_ports_from_bond(connection, id_list) - log.info(source) - log.info(target) - if source.get('causality', 'indifferent') == 'fixed effort out': - connection['causality'] = 'target' - elif source.get('causality', 'indifferent') == 'fixed flow out': - connection['causality'] = 'source' + # Fixed causalities + for block in graph.get("blocks", []): + for port in block.get('interface', {}).get('ports', []): + if port.get('type', '') != 'power': + continue + if port.get('causality', 'indifferent') == 'fixed effort out': + propagate_from_port(block, port, 'effort out', graph, id_list) + elif port.get('causality', 'indifferent') == 'fixed flow out': + propagate_from_port(block, port, 'flow out', graph, id_list) + + # Preferred causalities + for block in graph.get("blocks", []): + for port in block.get('interface', {}).get('ports', []): + if port.get('type', '') != 'power': + continue + if is_port_fully_assigned(block, port, graph): + continue + if port.get('causality', 'indifferent') == 'preferred effort out': + propagate_from_port(block, port, 'effort out', graph, id_list) + elif port.get('causality', 'indifferent') == 'preferred flow out': + propagate_from_port(block, port, 'flow out', graph, id_list) + + + + # Soft choices + for block in graph.get("blocks", []): + for port in block.get('interface', {}).get('ports', []): + if port.get('type', '') != 'power': + continue + if is_port_fully_assigned(block, port, graph): + continue + if port.get('causality', 'indifferent') == 'likes effort out': + propagate_from_port(block, port, 'effort out', graph, id_list) + elif port.get('causality', 'indifferent') == 'likes flow out': + propagate_from_port(block, port, 'flow out', graph, id_list) + elif port.get('causality', 'indifferent') in ['indifferent', 'single flow in', 'single effort in']: + # Force an arbitrary assignment on the first unassigned bond connected to this port + propagate_from_port(block, port, 'effort out', graph, id_list) + + # Last check + for con in graph.get('connections', []): + if con.get('type') == 'power' and con.get('causality', 'none') == 'none': + raise ValueError("System under-constrained: unresolved causal loops or disconnected elements remain") for block in graph.get("blocks", []): if isinstance(block, dict): @@ -104,31 +137,167 @@ def build_id_list(graph: dict[str, Any]) -> dict[str, Any]: visit(graph) return id_list -def get_ports_from_bond(bond: dict[str, Any], id_list: dict[str, Any]) -> tuple[dict[str,Any], dict[str,Any]]: - source = bond.get('source', None) - target = bond.get('target', None) - if source is None or target is None: - raise ValueError("Source and Target of a power bond cannot be None") - - source_component = id_list.get(source.get('block'), None) - target_component = id_list.get(target.get('block'), None) - if source_component is None or target_component is None: - raise ValueError("Source or Target blocks not found") - - def _find_port(ports: list[dict[str, Any]], port: str) -> dict[str, Any] | None: - for pi in ports: - if pi.get('id', '') == port: - return pi - return None +def is_port_fully_assigned(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any]) -> bool: + block_id = block.get('id') + port_id = port.get('id') + for connection in graph.get('connections', []): + if connection.get('type') != 'power': + continue + source = connection.get('source', {}) + target = connection.get('target', {}) + if ((source.get('block') == block_id and source.get('port') == port_id) + or (target.get('block') == block_id and target.get('port') == port_id)): + if connection.get('causality', 'none') == 'none': + return False + return True - source_port = _find_port(source_component.get('interface', {}).get('ports', []), source.get('port')) - target_port = _find_port(target_component.get('interface', {}).get('ports', []), target.get('port')) - if source_port is None or target_port is None: - raise ValueError("Source or Target port not found") +def propagate_from_port(block: dict[str, Any], port: dict[str, Any], desired_causality: str, graph: dict[str, Any], id_list: dict[str, Any]) -> None: + # find all connection on this specific port + attached_connections = [] + for con in graph.get('connections', []): + if con.get('type') == 'power': + if con.get('source', {}).get('block') == block.get('id') and con.get('source', {}).get('port') == port.get('id'): + attached_connections.append(con) + elif con.get('target', {}).get('block') == block.get('id') and con.get('target', {}).get('port') == port.get('id'): + attached_connections.append(con) + if not attached_connections: + # unconnected port + return - if source_port.get('type') != 'power': - raise ValueError("Source port is not a power port") - if target_port.get('type') != 'power': - raise ValueError("Target port is not a power port") + # find first unassigned connection on this port + target_connection = None + for con in attached_connections: + if con.get('causality', None) == 'none': + target_connection = con + break + if target_connection is None: + # Nothing left to resolve + return + + # Update + if target_connection.get('source').get('block') == block.get('id'): + target_connection['causality'] = 'target' if (desired_causality == 'effort out') else 'source' + else: + target_connection['causality'] = 'source' if (desired_causality == 'effort out') else 'target' + + propagate_to_neighbor_from_connection(block, target_connection, graph, id_list) + +def evaluate_junction_constraints(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any], id_list: dict[str, Any]) -> None: + # find all connection on this specific port + attached_connections = [] + for con in graph.get('connections', []): + if con.get('type') == 'power': + if con.get('source', {}).get('block') == block.get('id') and con.get('source', {}).get('port') == port.get('id'): + attached_connections.append(con) + elif con.get('target', {}).get('block') == block.get('id') and con.get('target', {}).get('port') == port.get('id'): + attached_connections.append(con) + if not attached_connections: + # unconnected port + return - return source_port, target_port + efforts_in = 0 + efforts_out = 0 + unnassigned_conns = [] + # Count current states relative to the junction port + for con in attached_connections: + if con.get('causality', 'none') == 'none': + unnassigned_conns.append(con) + continue + + is_source = (con.get('source').get('block') == block.get('id')) + if (is_source and con.get('causality', 'none')=='source') or (not is_source and con.get('causality', 'none')=='target'): + efforts_in += 1 + else: + efforts_out += 1 + + # Single effort in + if port.get('causality', 'indifferent') == 'single effort in': + # ERROR CHECK: Multiple sources/blocks trying to claim effort control on a 0-junction + if efforts_in>1: + raise ValueError(f"Critical causality conflict: Multiple blocks are dictating effort to 0 junction: {block.get('name')}") + + # 1 effort is coming in so all other ports must be effort out + if efforts_in == 1 and len(unnassigned_conns) > 0: + for con in unnassigned_conns: + desired = 'target' if (con.get('source').get('block')==block.get('id')) else 'source' + con['causality'] = desired + propagate_to_neighbor_from_connection(block, con, graph, id_list) + + # No effort is coming in yet and one left so must be effort in + if efforts_in == 0 and len(unnassigned_conns) == 1: + con = unnassigned_conns[0] + desired = 'source' if (con.get('source').get('block')==block.get('id')) else 'target' + con['causality'] = desired + propagate_to_neighbor_from_connection(block, con, graph, id_list) + + # Single flow in + elif port.get('causality', 'indifferent') == 'single flow in': + # ERROR CHECK: Multiple sources/blocks trying to claim flow control on a 1-junction + if efforts_out>1: + raise ValueError(f"Critical causality conflict: Multiple blocks are dictating flow to 1 junction: {block.get('name')}") + + # 1 flow is coming in so all other ports must be flow out + if efforts_out == 1 and len(unnassigned_conns) > 0: + for con in unnassigned_conns: + desired = 'source' if (con.get('source').get('block')==block.get('id')) else 'target' + con['causality'] = desired + propagate_to_neighbor_from_connection(block, con, graph, id_list) + + # No flow is coming in yet and one left so must be flow in + if efforts_out == 0 and len(unnassigned_conns) == 1: + con = unnassigned_conns[0] + desired = 'target' if (con.get('source').get('block')==block.get('id')) else 'source' + con['causality'] = desired + propagate_to_neighbor_from_connection(block, con, graph, id_list) + +def propagate_to_neighbor_from_connection(block: dict[str, Any], connection: dict[str, Any], graph: dict[str, Any], id_list: dict[str, Any]) -> None: + # Get the neighbor block and port on the other side of the bond + neighbor_id = connection.get('target').get('block') if connection.get('source').get('block') == block.get('id') else connection.get('source').get('block') + neighbor_port_id = connection.get('target').get('port') if connection.get('source').get('block') == block.get('id') else connection.get('source').get('port') + neighbor_block = id_list.get(neighbor_id, None) + if neighbor_block is None: + raise ValueError(f"No other side of the bond found on connection {connection.get('id')}") + neighbor_ports = neighbor_block.get('interface').get('ports', []) + neighbor_port = None + for p in neighbor_ports: + if p.get('id') == neighbor_port_id: + neighbor_port = p + break + if neighbor_port is None: + raise ValueError(f"No other side of the bond found on connection {connection.get('id')}") + + # Trigger evaluation on the neighbor + if neighbor_port.get('causality', 'indifferent') in ['single effort in', 'single flow in']: + evaluate_junction_constraints(neighbor_block, neighbor_port, graph, id_list) + else: + verify_component_compatibility(neighbor_block, neighbor_port, graph) + +def verify_component_compatibility(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any]) -> None: + # Find the connection we are evaluating + conn = None + for c in graph.get('connections', []): + if (c.get('source').get('block') == block.get('id') and c.get('source').get('port') == port.get('id')) or (c.get('target').get('block') == block.get('id') and c.get('target').get('port') == port.get('id')): + if c.get('causality', 'none') != 'none': + conn = c + break + + if conn is None: + return + + # Figure out what causality was pushed onto this port from the outside world + state = conn.get('causality', 'none') + is_source = conn.get('source', {}).get('block') == block.get('id') + effort_out = (is_source and state == 'target') or (not is_source and state == 'source') + port_causality = port.get('causality', 'indifferent') + + if port_causality == 'fixed effort out' and not effort_out: + raise ValueError(f"Critical source conflict: Fixed effort source '{block.get('name')}' forced into an input state.") + if port_causality == 'fixed flow out' and effort_out: + raise ValueError(f"Critical source conflict: Fixed flow source '{block.get('name')}' forced into an input state.") + + if port_causality == 'preferred effort out' and not effort_out: + conn['causality'] = f'warn_{state}' + # log.warning(f"Storage block '{block.get('name')}' was forced into derivative causality (dependent state).") + if port_causality == 'preferred flow out' and effort_out: + conn['causality'] = f'warn_{state}' + # log.warning(f"Storage block '{block.get('name')}' was forced into derivative causality (dependent state).") diff --git a/BEdit/src/bedit/core/model.py b/BEdit/src/bedit/core/model.py index 83f67d3..ace87e0 100644 --- a/BEdit/src/bedit/core/model.py +++ b/BEdit/src/bedit/core/model.py @@ -6,7 +6,11 @@ from typing import Any from uuid import uuid4 from bedit.core.port_types import PortTypeRegistry -from bedit.core.power_domains import POWER_CAUSALITIES, POWER_DOMAINS +from bedit.core.power_domains import ( + MULTI_CONNECTION_POWER_CAUSALITIES, + POWER_CAUSALITIES, + POWER_DOMAINS, +) def _dimensions(data: Any, subject: str) -> tuple[int, int]: @@ -547,6 +551,14 @@ class GraphDocument: raise ValueError(f"Power port {port.name!r} has an unknown domain") if port.causality not in POWER_CAUSALITIES: raise ValueError(f"Power port {port.name!r} has invalid causality") + if ( + port.causality in MULTI_CONNECTION_POWER_CAUSALITIES + and not port.allows_multiple_connections + ): + raise ValueError( + f"Power port {port.name!r} requires multiple connections " + f"for causality {port.causality!r}" + ) if not port.value_type.strip(): raise ValueError(f"Port {port.name!r} has no value type") if port.rows < 1 or port.columns < 1: diff --git a/BEdit/src/bedit/core/power_domains.py b/BEdit/src/bedit/core/power_domains.py index ce8b003..d98e15b 100644 --- a/BEdit/src/bedit/core/power_domains.py +++ b/BEdit/src/bedit/core/power_domains.py @@ -20,7 +20,7 @@ def power_domain(domain_id: str) -> PowerDomain: return next((domain for domain in POWER_DOMAINS if domain.id == domain_id), POWER_DOMAINS[0]) -POWER_CAUSALITIES = ( +STANDARD_POWER_CAUSALITIES = ( "fixed flow out", "fixed effort out", "preferred flow out", @@ -29,3 +29,21 @@ POWER_CAUSALITIES = ( "likes effort out", "indifferent", ) + +MULTI_CONNECTION_POWER_CAUSALITIES = ( + "single effort in", + "single flow in", +) + +POWER_CAUSALITIES = ( + *STANDARD_POWER_CAUSALITIES, + *MULTI_CONNECTION_POWER_CAUSALITIES, +) + + +def power_causalities(allows_multiple_connections: bool = False) -> tuple[str, ...]: + """Return the causalities available for one power port configuration.""" + + if allows_multiple_connections: + return POWER_CAUSALITIES + return STANDARD_POWER_CAUSALITIES diff --git a/BEdit/src/bedit/core/simulation/service.py b/BEdit/src/bedit/core/simulation/service.py index 2613c6a..fa7b76d 100644 --- a/BEdit/src/bedit/core/simulation/service.py +++ b/BEdit/src/bedit/core/simulation/service.py @@ -48,6 +48,8 @@ class Simulation: ) -> None: """Compose and retain the active graph's Modelica representation.""" + return + self.model_path = None self.compose_source(graph) diff --git a/BEdit/src/bedit/gui/controllers/document.py b/BEdit/src/bedit/gui/controllers/document.py index cceb5b1..e5c8f19 100644 --- a/BEdit/src/bedit/gui/controllers/document.py +++ b/BEdit/src/bedit/gui/controllers/document.py @@ -44,6 +44,7 @@ from bedit.core.bond_graph import infer_causality from bedit.core.simulation import Simulation from bedit.core.port_types import PortTypeRegistry from bedit.core.serializer import DocumentSerializer +from bedit.gui.preferences import application_settings class DocumentController(QObject): @@ -479,7 +480,9 @@ class DocumentController(QObject): error_callback, ) - def _infer_active_graph_causality(self, component: Component) -> None: + def _infer_active_graph_causality( + self, component: Component, *, emit_reset: bool = True + ) -> None: """Infer causality and copy the derived values into the live model.""" inferred = infer_causality(component.to_dict()) @@ -505,7 +508,8 @@ class DocumentController(QObject): if changed: if self.document is not None: self.document.validate() - self.documentReset.emit() + if emit_reset: + self.documentReset.emit() def set_route_waypoints(self, item_kind: str, item_id: str, waypoints: list[QPointF]) -> None: item = ( @@ -1175,6 +1179,10 @@ class DocumentController(QObject): def _insert_connection(self, owner_id: str, connection: Connection) -> None: self._graph_for(owner_id).connections[connection.id] = connection + if self.document is not None and self._infer_causality_on_connection_change(): + owner = self.document.find_component(owner_id) + if owner is not None: + self._infer_active_graph_causality(owner, emit_reset=False) if owner_id == self.active_component_id: self.connectionAdded.emit(connection.id) self.documentReset.emit() @@ -1211,10 +1219,20 @@ class DocumentController(QObject): def _remove_connection(self, owner_id: str, connection_id: str) -> None: self._graph_for(owner_id).connections.pop(connection_id, None) + if self.document is not None and self._infer_causality_on_connection_change(): + owner = self.document.find_component(owner_id) + if owner is not None: + self._infer_active_graph_causality(owner, emit_reset=False) if owner_id == self.active_component_id: self.connectionRemoved.emit(connection_id) self.documentReset.emit() + @staticmethod + def _infer_causality_on_connection_change() -> bool: + return application_settings().value( + "bondGraph/inferCausalityOnConnectionChange", True, type=bool + ) + def _insert_annotation(self, owner_id: str, annotation: Annotation) -> None: self._graph_for(owner_id).annotations[annotation.id] = annotation if owner_id == self.active_component_id: diff --git a/BEdit/src/bedit/gui/dialogs/port_options.py b/BEdit/src/bedit/gui/dialogs/port_options.py index 1e9493c..451e652 100644 --- a/BEdit/src/bedit/gui/dialogs/port_options.py +++ b/BEdit/src/bedit/gui/dialogs/port_options.py @@ -6,7 +6,7 @@ from PySide6.QtWidgets import QDialog, QDialogButtonBox, QListWidgetItem, QMessa from bedit.core.model import Component, Port from bedit.core.physical_types import QUANTITIES, UNITS -from bedit.core.power_domains import POWER_CAUSALITIES, POWER_DOMAINS, power_domain +from bedit.core.power_domains import POWER_DOMAINS, power_causalities, power_domain from bedit.core.port_types import PortTypeRegistry from bedit.gui.generated.ui_port_options_dialog import Ui_PortOptionsDialog @@ -37,14 +37,16 @@ class PortOptionsDialog(QDialog): self.ui.orientationCombo.setItemData(2, "indifferent") for domain in POWER_DOMAINS: self.ui.domainCombo.addItem(domain.display_name, domain.id) - self.ui.causalityCombo.addItems(POWER_CAUSALITIES) + self._refresh_causality_options() self.ui.portList.currentRowChanged.connect(self._load_current) self.ui.addPortButton.clicked.connect(self.add_port) self.ui.removePortButton.clicked.connect(self.remove_port) self.ui.nameEdit.textEdited.connect(self._store_current) self.ui.typeCombo.currentIndexChanged.connect(self._port_type_changed) self.ui.orientationCombo.currentIndexChanged.connect(self._store_current) - self.ui.multipleConnectionsCheckBox.toggled.connect(self._store_current) + self.ui.multipleConnectionsCheckBox.toggled.connect( + self._multiple_connections_changed + ) self.ui.valueTypeCombo.currentTextChanged.connect(self._store_current) self.ui.quantityCombo.currentTextChanged.connect(self._store_current) self.ui.unitCombo.currentTextChanged.connect(self._store_current) @@ -96,6 +98,11 @@ class PortOptionsDialog(QDialog): self.ui.columnsSpin.setValue(port.columns) self.ui.descriptionEdit.setPlainText(port.description) self.ui.domainCombo.setCurrentIndex(self.ui.domainCombo.findData(port.domain)) + self._refresh_causality_options( + port.causality, + port_type=port.type, + allows_multiple_connections=port.allows_multiple_connections, + ) self.ui.causalityCombo.setCurrentText(port.causality) self.ui.powerDescriptionEdit.setPlainText(port.description) self._show_type_options(port.type) @@ -120,9 +127,36 @@ class PortOptionsDialog(QDialog): indifferent_item.setEnabled(port_type == "power") if port_type != "power" and self.ui.orientationCombo.currentData() == "indifferent": self.ui.orientationCombo.setCurrentIndex(0) + self._refresh_causality_options(port_type=port_type) self._show_type_options(port_type) self._store_current() + def _multiple_connections_changed(self) -> None: + self._refresh_causality_options() + self._store_current() + + def _refresh_causality_options( + self, + selected: str | None = None, + *, + port_type: str | None = None, + allows_multiple_connections: bool | None = None, + ) -> None: + selected = selected or self.ui.causalityCombo.currentText() or "indifferent" + port_type = port_type or self.ui.typeCombo.currentData() + if allows_multiple_connections is None: + allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked() + choices = power_causalities( + port_type == "power" and allows_multiple_connections + ) + self.ui.causalityCombo.blockSignals(True) + self.ui.causalityCombo.clear() + self.ui.causalityCombo.addItems(choices) + self.ui.causalityCombo.setCurrentText( + selected if selected in choices else "indifferent" + ) + self.ui.causalityCombo.blockSignals(False) + def _show_type_options(self, port_type: str) -> None: self.ui.orientationCombo.model().item(2).setEnabled(port_type == "power") page = { diff --git a/BEdit/src/bedit/gui/dialogs/settings.py b/BEdit/src/bedit/gui/dialogs/settings.py index d6edea9..923054c 100644 --- a/BEdit/src/bedit/gui/dialogs/settings.py +++ b/BEdit/src/bedit/gui/dialogs/settings.py @@ -47,6 +47,9 @@ class SettingsDialog(QDialog): self.ui.graphGridSpinBox.setValue(self.graph_grid_size(self.settings)) self.ui.graphSnapSpinBox.setValue(self.graph_snap_size(self.settings)) self.ui.iconGridSpinBox.setValue(self.icon_grid_size(self.settings)) + self.ui.automaticCausalityCheckBox.setChecked( + self.infer_causality_on_connection_change(self.settings) + ) self.ui.openModelicaPathEdit.setText(self.openmodelica_path(self.settings)) self._load_syntax_styles() self._update_remove_button() @@ -132,6 +135,15 @@ class SettingsDialog(QDialog): settings = settings if settings is not None else application_settings() return settings.value("grid/iconSize", 8, type=int) + @staticmethod + def infer_causality_on_connection_change( + settings: QSettings | None = None, + ) -> bool: + settings = settings if settings is not None else application_settings() + return settings.value( + "bondGraph/inferCausalityOnConnectionChange", True, type=bool + ) + @staticmethod def openmodelica_path(settings: QSettings | None = None) -> str: settings = settings if settings is not None else application_settings() @@ -197,6 +209,10 @@ class SettingsDialog(QDialog): self.settings.setValue("grid/graphSize", self.ui.graphGridSpinBox.value()) self.settings.setValue("grid/graphSnapSize", self.ui.graphSnapSpinBox.value()) self.settings.setValue("grid/iconSize", self.ui.iconGridSpinBox.value()) + self.settings.setValue( + "bondGraph/inferCausalityOnConnectionChange", + self.ui.automaticCausalityCheckBox.isChecked(), + ) self.settings.setValue( "simulation/openModelicaPath", self.ui.openModelicaPathEdit.text().strip(), diff --git a/BEdit/src/bedit/gui/generated/ui_settings_dialog.py b/BEdit/src/bedit/gui/generated/ui_settings_dialog.py index 3c46d41..9397f08 100644 --- a/BEdit/src/bedit/gui/generated/ui_settings_dialog.py +++ b/BEdit/src/bedit/gui/generated/ui_settings_dialog.py @@ -15,12 +15,12 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, QFont, QFontDatabase, QGradient, QIcon, QImage, QKeySequence, QLinearGradient, QPainter, QPalette, QPixmap, QRadialGradient, QTransform) -from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView, QApplication, QDialog, - QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, - QHeaderView, QLabel, QLineEdit, QListWidget, - QListWidgetItem, QPushButton, QSizePolicy, QSpacerItem, - QSpinBox, QTabWidget, QTableWidget, QTableWidgetItem, - QVBoxLayout, QWidget) +from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView, QApplication, QCheckBox, + QDialog, QDialogButtonBox, QFormLayout, QGroupBox, + QHBoxLayout, QHeaderView, QLabel, QLineEdit, + QListWidget, QListWidgetItem, QPushButton, QSizePolicy, + QSpacerItem, QSpinBox, QTabWidget, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget) class Ui_SettingsDialog(object): def setupUi(self, SettingsDialog): @@ -104,6 +104,34 @@ class Ui_SettingsDialog(object): self.generalLayout.addItem(self.generalSpacer) self.settingsTabs.addTab(self.generalTab, "") + self.bondGraphTab = QWidget() + self.bondGraphTab.setObjectName(u"bondGraphTab") + self.bondGraphLayout = QVBoxLayout(self.bondGraphTab) + self.bondGraphLayout.setObjectName(u"bondGraphLayout") + self.causalityGroupBox = QGroupBox(self.bondGraphTab) + self.causalityGroupBox.setObjectName(u"causalityGroupBox") + self.causalityLayout = QVBoxLayout(self.causalityGroupBox) + self.causalityLayout.setObjectName(u"causalityLayout") + self.automaticCausalityCheckBox = QCheckBox(self.causalityGroupBox) + self.automaticCausalityCheckBox.setObjectName(u"automaticCausalityCheckBox") + self.automaticCausalityCheckBox.setChecked(True) + + self.causalityLayout.addWidget(self.automaticCausalityCheckBox) + + self.automaticCausalityHintLabel = QLabel(self.causalityGroupBox) + self.automaticCausalityHintLabel.setObjectName(u"automaticCausalityHintLabel") + self.automaticCausalityHintLabel.setWordWrap(True) + + self.causalityLayout.addWidget(self.automaticCausalityHintLabel) + + + self.bondGraphLayout.addWidget(self.causalityGroupBox) + + self.bondGraphSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.bondGraphLayout.addItem(self.bondGraphSpacer) + + self.settingsTabs.addTab(self.bondGraphTab, "") self.simulationTab = QWidget() self.simulationTab.setObjectName(u"simulationTab") self.simulationTabLayout = QVBoxLayout(self.simulationTab) @@ -247,6 +275,10 @@ class Ui_SettingsDialog(object): self.iconGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Icon grid size:", None)) self.iconGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None)) self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None)) + self.causalityGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Causality", None)) + self.automaticCausalityCheckBox.setText(QCoreApplication.translate("SettingsDialog", u"Infer causality when connections are added or removed", None)) + self.automaticCausalityHintLabel.setText(QCoreApplication.translate("SettingsDialog", u"When disabled, displayed causalities are updated only when compiling, exporting, or running the model.", None)) + self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.bondGraphTab), QCoreApplication.translate("SettingsDialog", u"Bond graph", None)) self.openModelicaGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"OpenModelica", None)) self.openModelicaPathLabel.setText(QCoreApplication.translate("SettingsDialog", u"OpenModelica executable:", None)) self.openModelicaPathEdit.setPlaceholderText(QCoreApplication.translate("SettingsDialog", u"Leave empty to find omc on PATH", None)) diff --git a/BEdit/src/bedit/gui/graphics/connection_styles.py b/BEdit/src/bedit/gui/graphics/connection_styles.py index b716da3..a089422 100644 --- a/BEdit/src/bedit/gui/graphics/connection_styles.py +++ b/BEdit/src/bedit/gui/graphics/connection_styles.py @@ -27,7 +27,7 @@ class ConnectionStyle: # This is the intentional code-level styling point for every port/connection type. CONNECTION_STYLES: dict[str, ConnectionStyle] = { "signal": ConnectionStyle(), - "power": ConnectionStyle(width=3.0, arrow_style="half", color="#000000"), + "power": ConnectionStyle(width=3.0, arrow_style="half", color="#000000", arrow_size=20.0), } diff --git a/BEdit/src/bedit/gui/graphics/workspace.py b/BEdit/src/bedit/gui/graphics/workspace.py index 5e17a96..6d34a5e 100644 --- a/BEdit/src/bedit/gui/graphics/workspace.py +++ b/BEdit/src/bedit/gui/graphics/workspace.py @@ -397,6 +397,19 @@ class ConnectionGraphicsItem(QGraphicsPathItem): self.name_label: NameLabelItem | None = None self.sync_name_label(connection) + def boundingRect(self) -> QRectF: # noqa: N802 + """Include custom arrowheads and causality marks in Qt's repaint area.""" + + decoration_margin = ( + max(self.style.arrow_size, 6.0) + self.style.selected_width / 2 + 1.0 + ) + return super().boundingRect().adjusted( + -decoration_margin, + -decoration_margin, + decoration_margin, + decoration_margin, + ) + def sync_name_label(self, connection: Connection) -> None: visible = bool(connection.properties.get("showName", False)) if not visible: diff --git a/BEdit/ui/settings_dialog.ui b/BEdit/ui/settings_dialog.ui index ae31987..3ce906c 100644 --- a/BEdit/ui/settings_dialog.ui +++ b/BEdit/ui/settings_dialog.ui @@ -86,6 +86,55 @@ + + + Bond graph + + + + + + Causality + + + + + + Infer causality when connections are added or removed + + + true + + + + + + + When disabled, displayed causalities are updated only when compiling, exporting, or running the model. + + + true + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + Simulation diff --git a/BEdit/untitled.bedit.json b/BEdit/untitled.bedit.json index 1c4e573..a2ff80d 100644 --- a/BEdit/untitled.bedit.json +++ b/BEdit/untitled.bedit.json @@ -216,7 +216,7 @@ "description": "", "orientation": "indifferent", "domain": "power", - "causality": "indifferent" + "causality": "single flow in" } ] }, @@ -451,11 +451,431 @@ "initialEquations": "" } } + }, + { + "id": "140ae14b-9dbb-4756-aa25-29101ef2a99e", + "name": "0", + "position": { + "x": -96.0, + "y": -32.0 + }, + "rotation": 0.0, + "interface": { + "ports": [ + { + "id": "port-3e53bf80", + "name": "p", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "power", + "multipleConnections": true, + "valueType": "real", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "", + "orientation": "indifferent", + "domain": "power", + "causality": "single effort in" + } + ] + }, + "parameters": [], + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "type": "text", + "x": 40.0, + "y": 40.0, + "width": 48.0, + "height": 48.0, + "text": "0", + "color": "#303030", + "fontSize": 32.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "fill": "#ffffff" + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "effort = p[1].e;\nsum(p[i].f for i in 1:$p_N$) = 0;\nfor i in 2:$p_N$ loop\n p[i].e = p[i-1].e;\nend for;", + "declarations": "Real effort;", + "initialEquations": "" + } + } + }, + { + "id": "6a6fbadd-28ec-4d10-b854-876752a6b287", + "name": "C0", + "position": { + "x": -96.0, + "y": 96.0 + }, + "rotation": 0.0, + "interface": { + "ports": [ + { + "id": "port-2e1d884f", + "name": "p", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "power", + "multipleConnections": false, + "valueType": "real", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "", + "orientation": "input", + "domain": "power", + "causality": "preferred effort out" + }, + { + "id": "port-ca7716d5", + "name": "state", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false, + "valueType": "real", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "", + "orientation": "output", + "domain": "power", + "causality": "indifferent" + } + ] + }, + "parameters": [ + { + "id": "parameter-d056bc27", + "name": "c", + "type": "real", + "value": "1", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "" + } + ], + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "type": "text", + "x": 40.0, + "y": 40.0, + "width": 48.0, + "height": 48.0, + "text": "C", + "color": "#303030", + "fontSize": 32.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "fill": "#ffffff" + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "der(state) = p.f;\np.e = state/c;", + "declarations": "", + "initialEquations": "" + } + } + }, + { + "id": "9c1aa152-f9fb-459a-9911-e90dd5a9beb3", + "name": "R0", + "position": { + "x": 32.0, + "y": -32.0 + }, + "rotation": 0.0, + "interface": { + "ports": [ + { + "id": "port-2e1d884f", + "name": "p", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "power", + "multipleConnections": false, + "valueType": "real", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "", + "orientation": "input", + "domain": "power", + "causality": "indifferent" + } + ] + }, + "parameters": [ + { + "id": "parameter-d056bc27", + "name": "r", + "type": "real", + "value": "1", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "" + } + ], + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "type": "text", + "x": 40.0, + "y": 40.0, + "width": 48.0, + "height": 48.0, + "text": "R", + "color": "#303030", + "fontSize": 32.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "fill": "#ffffff" + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "p.e = r*p.f;", + "declarations": "", + "initialEquations": "" + } + } + }, + { + "id": "f37555f8-8a8b-455a-bd6d-a71343a23002", + "name": "I", + "position": { + "x": -224.0, + "y": -32.0 + }, + "rotation": 0.0, + "interface": { + "ports": [ + { + "id": "port-2e1d884f", + "name": "p", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 64.0, + "y": 64.0 + } + }, + "type": "power", + "multipleConnections": false, + "valueType": "real", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "", + "orientation": "input", + "domain": "power", + "causality": "preferred flow out" + }, + { + "id": "port-ca7716d5", + "name": "state", + "position": { + "x": 0.0, + "y": 0.0 + }, + "properties": { + "iconPosition": { + "x": 88.0, + "y": 40.0 + } + }, + "type": "signal", + "multipleConnections": false, + "valueType": "real", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "", + "orientation": "output", + "domain": "power", + "causality": "indifferent" + } + ] + }, + "parameters": [ + { + "id": "parameter-d056bc27", + "name": "i", + "type": "real", + "value": "1", + "quantity": "", + "unit": "", + "dimensions": { + "rows": 1, + "columns": 1 + }, + "description": "" + } + ], + "icon": { + "shape": "rectangle", + "fill": "#f4f4f4", + "border": "#303030", + "text": "Text", + "size": { + "width": 128.0, + "height": 128.0 + }, + "elements": [ + { + "type": "text", + "x": 40.0, + "y": 40.0, + "width": 48.0, + "height": 48.0, + "text": "I", + "color": "#303030", + "fontSize": 32.0, + "lineStyle": "solid", + "lineWidth": 1.5, + "stroke": "#303030", + "fill": "#ffffff" + } + ] + }, + "properties": { + "showName": false + }, + "library": { + "showSubtree": true + }, + "implementation": { + "kind": "text", + "source": { + "equations": "der(state) = p.e;\np.f = state/i;", + "declarations": "", + "initialEquations": "" + } + } } ], "connections": [ { - "id": "4b09ba21-baa0-4db7-9431-4c919478bce4", + "id": "55c04603-2ae5-44d2-b3f3-493a73e88d73", "source": { "block": "063a6341-cb18-4bfe-a1e4-00957a88a874", "port": "port-2e1d884f" @@ -469,16 +889,16 @@ "waypoints": [] }, "type": "power", - "causality": "none" + "causality": "target" }, { - "id": "a73a99a1-54e2-461d-a9a7-2d6d11d500f4", + "id": "5e7f801a-eb4a-46d2-9279-b4e81e1e011b", "source": { - "block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6", + "block": "140ae14b-9dbb-4756-aa25-29101ef2a99e", "port": "port-3e53bf80" }, "target": { - "block": "ea95e8ee-7f2d-4e14-a65d-d56bec5b4eb2", + "block": "9c1aa152-f9fb-459a-9911-e90dd5a9beb3", "port": "port-2e1d884f" }, "name": "", @@ -486,10 +906,27 @@ "waypoints": [] }, "type": "power", - "causality": "none" + "causality": "target" }, { - "id": "dd5fb1e2-b125-4b5f-a792-dac89dd89ad3", + "id": "ae95fbfc-c771-47f2-8f9e-1c823daaf6b8", + "source": { + "block": "140ae14b-9dbb-4756-aa25-29101ef2a99e", + "port": "port-3e53bf80" + }, + "target": { + "block": "6a6fbadd-28ec-4d10-b854-876752a6b287", + "port": "port-2e1d884f" + }, + "name": "", + "properties": { + "waypoints": [] + }, + "type": "power", + "causality": "source" + }, + { + "id": "45886c5b-8dec-4212-b13d-1f2996710a6b", "source": { "block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6", "port": "port-3e53bf80" @@ -503,7 +940,58 @@ "waypoints": [] }, "type": "power", - "causality": "none" + "causality": "source" + }, + { + "id": "476a20b4-ff02-4186-ac09-53f91ed06fcd", + "source": { + "block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6", + "port": "port-3e53bf80" + }, + "target": { + "block": "ea95e8ee-7f2d-4e14-a65d-d56bec5b4eb2", + "port": "port-2e1d884f" + }, + "name": "", + "properties": { + "waypoints": [] + }, + "type": "power", + "causality": "target" + }, + { + "id": "48b58a43-4d83-43ec-b502-1ff038445696", + "source": { + "block": "140ae14b-9dbb-4756-aa25-29101ef2a99e", + "port": "port-3e53bf80" + }, + "target": { + "block": "f37555f8-8a8b-455a-bd6d-a71343a23002", + "port": "port-2e1d884f" + }, + "name": "", + "properties": { + "waypoints": [] + }, + "type": "power", + "causality": "target" + }, + { + "id": "d6940de3-5b4c-42be-a247-639b3803d85e", + "source": { + "block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6", + "port": "port-3e53bf80" + }, + "target": { + "block": "140ae14b-9dbb-4756-aa25-29101ef2a99e", + "port": "port-3e53bf80" + }, + "name": "", + "properties": { + "waypoints": [] + }, + "type": "power", + "causality": "source" } ], "annotations": [],