causality inference done

This commit is contained in:
2026-07-22 18:04:07 +02:00
parent 32154386e8
commit c918a6a428
13 changed files with 911 additions and 59 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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