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": [],