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

@@ -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 - 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 connected. Signal ports connect by type; power ports additionally require the
same domain. Editable power domains and causalities live in 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 - Power ports may use `indifferent` orientation and can act as either connection
endpoint. For two indifferent ports, click order determines source/arrow endpoint. For two indifferent ports, click order determines source/arrow
direction; otherwise output/input semantics determine direction. direction; otherwise output/input semantics determine direction.

View File

@@ -37,16 +37,49 @@ def infer_causality(component: dict[str, Any], toplevel: bool = True) -> dict[st
id_list = build_id_list(component) id_list = build_id_list(component)
# Check fixed causality # Fixed causalities
for connection in graph.get("connections", []): for block in graph.get("blocks", []):
if isinstance(connection, dict) and connection.get("type") == "power": for port in block.get('interface', {}).get('ports', []):
source, target = get_ports_from_bond(connection, id_list) if port.get('type', '') != 'power':
log.info(source) continue
log.info(target) if port.get('causality', 'indifferent') == 'fixed effort out':
if source.get('causality', 'indifferent') == 'fixed effort out': propagate_from_port(block, port, 'effort out', graph, id_list)
connection['causality'] = 'target' elif port.get('causality', 'indifferent') == 'fixed flow out':
elif source.get('causality', 'indifferent') == 'fixed flow out': propagate_from_port(block, port, 'flow out', graph, id_list)
connection['causality'] = 'source'
# 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", []): for block in graph.get("blocks", []):
if isinstance(block, dict): if isinstance(block, dict):
@@ -104,31 +137,167 @@ def build_id_list(graph: dict[str, Any]) -> dict[str, Any]:
visit(graph) visit(graph)
return id_list return id_list
def get_ports_from_bond(bond: dict[str, Any], id_list: dict[str, Any]) -> tuple[dict[str,Any], dict[str,Any]]: def is_port_fully_assigned(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any]) -> bool:
source = bond.get('source', None) block_id = block.get('id')
target = bond.get('target', None) port_id = port.get('id')
if source is None or target is None: for connection in graph.get('connections', []):
raise ValueError("Source and Target of a power bond cannot be None") if connection.get('type') != 'power':
continue
source_component = id_list.get(source.get('block'), None) source = connection.get('source', {})
target_component = id_list.get(target.get('block'), None) target = connection.get('target', {})
if source_component is None or target_component is None: if ((source.get('block') == block_id and source.get('port') == port_id)
raise ValueError("Source or Target blocks not found") or (target.get('block') == block_id and target.get('port') == port_id)):
if connection.get('causality', 'none') == 'none':
def _find_port(ports: list[dict[str, Any]], port: str) -> dict[str, Any] | None: return False
for pi in ports: return True
if pi.get('id', '') == port:
return pi
return None
source_port = _find_port(source_component.get('interface', {}).get('ports', []), source.get('port')) 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:
target_port = _find_port(target_component.get('interface', {}).get('ports', []), target.get('port')) # find all connection on this specific port
if source_port is None or target_port is None: attached_connections = []
raise ValueError("Source or Target port not found") 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': # find first unassigned connection on this port
raise ValueError("Source port is not a power port") target_connection = None
if target_port.get('type') != 'power': for con in attached_connections:
raise ValueError("Target port is not a power port") 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 uuid import uuid4
from bedit.core.port_types import PortTypeRegistry 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]: 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") raise ValueError(f"Power port {port.name!r} has an unknown domain")
if port.causality not in POWER_CAUSALITIES: if port.causality not in POWER_CAUSALITIES:
raise ValueError(f"Power port {port.name!r} has invalid causality") 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(): if not port.value_type.strip():
raise ValueError(f"Port {port.name!r} has no value type") raise ValueError(f"Port {port.name!r} has no value type")
if port.rows < 1 or port.columns < 1: 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]) 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 flow out",
"fixed effort out", "fixed effort out",
"preferred flow out", "preferred flow out",
@@ -29,3 +29,21 @@ POWER_CAUSALITIES = (
"likes effort out", "likes effort out",
"indifferent", "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: ) -> None:
"""Compose and retain the active graph's Modelica representation.""" """Compose and retain the active graph's Modelica representation."""
return
self.model_path = None self.model_path = None
self.compose_source(graph) 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.simulation import Simulation
from bedit.core.port_types import PortTypeRegistry from bedit.core.port_types import PortTypeRegistry
from bedit.core.serializer import DocumentSerializer from bedit.core.serializer import DocumentSerializer
from bedit.gui.preferences import application_settings
class DocumentController(QObject): class DocumentController(QObject):
@@ -479,7 +480,9 @@ class DocumentController(QObject):
error_callback, 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.""" """Infer causality and copy the derived values into the live model."""
inferred = infer_causality(component.to_dict()) inferred = infer_causality(component.to_dict())
@@ -505,7 +508,8 @@ class DocumentController(QObject):
if changed: if changed:
if self.document is not None: if self.document is not None:
self.document.validate() 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: def set_route_waypoints(self, item_kind: str, item_id: str, waypoints: list[QPointF]) -> None:
item = ( item = (
@@ -1175,6 +1179,10 @@ class DocumentController(QObject):
def _insert_connection(self, owner_id: str, connection: Connection) -> None: def _insert_connection(self, owner_id: str, connection: Connection) -> None:
self._graph_for(owner_id).connections[connection.id] = connection 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: if owner_id == self.active_component_id:
self.connectionAdded.emit(connection.id) self.connectionAdded.emit(connection.id)
self.documentReset.emit() self.documentReset.emit()
@@ -1211,10 +1219,20 @@ class DocumentController(QObject):
def _remove_connection(self, owner_id: str, connection_id: str) -> None: def _remove_connection(self, owner_id: str, connection_id: str) -> None:
self._graph_for(owner_id).connections.pop(connection_id, 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: if owner_id == self.active_component_id:
self.connectionRemoved.emit(connection_id) self.connectionRemoved.emit(connection_id)
self.documentReset.emit() 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: def _insert_annotation(self, owner_id: str, annotation: Annotation) -> None:
self._graph_for(owner_id).annotations[annotation.id] = annotation self._graph_for(owner_id).annotations[annotation.id] = annotation
if owner_id == self.active_component_id: 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.model import Component, Port
from bedit.core.physical_types import QUANTITIES, UNITS 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.core.port_types import PortTypeRegistry
from bedit.gui.generated.ui_port_options_dialog import Ui_PortOptionsDialog from bedit.gui.generated.ui_port_options_dialog import Ui_PortOptionsDialog
@@ -37,14 +37,16 @@ class PortOptionsDialog(QDialog):
self.ui.orientationCombo.setItemData(2, "indifferent") self.ui.orientationCombo.setItemData(2, "indifferent")
for domain in POWER_DOMAINS: for domain in POWER_DOMAINS:
self.ui.domainCombo.addItem(domain.display_name, domain.id) 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.portList.currentRowChanged.connect(self._load_current)
self.ui.addPortButton.clicked.connect(self.add_port) self.ui.addPortButton.clicked.connect(self.add_port)
self.ui.removePortButton.clicked.connect(self.remove_port) self.ui.removePortButton.clicked.connect(self.remove_port)
self.ui.nameEdit.textEdited.connect(self._store_current) self.ui.nameEdit.textEdited.connect(self._store_current)
self.ui.typeCombo.currentIndexChanged.connect(self._port_type_changed) self.ui.typeCombo.currentIndexChanged.connect(self._port_type_changed)
self.ui.orientationCombo.currentIndexChanged.connect(self._store_current) 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.valueTypeCombo.currentTextChanged.connect(self._store_current)
self.ui.quantityCombo.currentTextChanged.connect(self._store_current) self.ui.quantityCombo.currentTextChanged.connect(self._store_current)
self.ui.unitCombo.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.columnsSpin.setValue(port.columns)
self.ui.descriptionEdit.setPlainText(port.description) self.ui.descriptionEdit.setPlainText(port.description)
self.ui.domainCombo.setCurrentIndex(self.ui.domainCombo.findData(port.domain)) 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.causalityCombo.setCurrentText(port.causality)
self.ui.powerDescriptionEdit.setPlainText(port.description) self.ui.powerDescriptionEdit.setPlainText(port.description)
self._show_type_options(port.type) self._show_type_options(port.type)
@@ -120,9 +127,36 @@ class PortOptionsDialog(QDialog):
indifferent_item.setEnabled(port_type == "power") indifferent_item.setEnabled(port_type == "power")
if port_type != "power" and self.ui.orientationCombo.currentData() == "indifferent": if port_type != "power" and self.ui.orientationCombo.currentData() == "indifferent":
self.ui.orientationCombo.setCurrentIndex(0) self.ui.orientationCombo.setCurrentIndex(0)
self._refresh_causality_options(port_type=port_type)
self._show_type_options(port_type) self._show_type_options(port_type)
self._store_current() 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: def _show_type_options(self, port_type: str) -> None:
self.ui.orientationCombo.model().item(2).setEnabled(port_type == "power") self.ui.orientationCombo.model().item(2).setEnabled(port_type == "power")
page = { page = {

View File

@@ -47,6 +47,9 @@ class SettingsDialog(QDialog):
self.ui.graphGridSpinBox.setValue(self.graph_grid_size(self.settings)) self.ui.graphGridSpinBox.setValue(self.graph_grid_size(self.settings))
self.ui.graphSnapSpinBox.setValue(self.graph_snap_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.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.ui.openModelicaPathEdit.setText(self.openmodelica_path(self.settings))
self._load_syntax_styles() self._load_syntax_styles()
self._update_remove_button() self._update_remove_button()
@@ -132,6 +135,15 @@ class SettingsDialog(QDialog):
settings = settings if settings is not None else application_settings() settings = settings if settings is not None else application_settings()
return settings.value("grid/iconSize", 8, type=int) 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 @staticmethod
def openmodelica_path(settings: QSettings | None = None) -> str: def openmodelica_path(settings: QSettings | None = None) -> str:
settings = settings if settings is not None else application_settings() 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/graphSize", self.ui.graphGridSpinBox.value())
self.settings.setValue("grid/graphSnapSize", self.ui.graphSnapSpinBox.value()) self.settings.setValue("grid/graphSnapSize", self.ui.graphSnapSpinBox.value())
self.settings.setValue("grid/iconSize", self.ui.iconGridSpinBox.value()) self.settings.setValue("grid/iconSize", self.ui.iconGridSpinBox.value())
self.settings.setValue(
"bondGraph/inferCausalityOnConnectionChange",
self.ui.automaticCausalityCheckBox.isChecked(),
)
self.settings.setValue( self.settings.setValue(
"simulation/openModelicaPath", "simulation/openModelicaPath",
self.ui.openModelicaPathEdit.text().strip(), self.ui.openModelicaPathEdit.text().strip(),

View File

@@ -15,12 +15,12 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon, QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter, QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform) QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView, QApplication, QDialog, from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView, QApplication, QCheckBox,
QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, QDialog, QDialogButtonBox, QFormLayout, QGroupBox,
QHeaderView, QLabel, QLineEdit, QListWidget, QHBoxLayout, QHeaderView, QLabel, QLineEdit,
QListWidgetItem, QPushButton, QSizePolicy, QSpacerItem, QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QSpinBox, QTabWidget, QTableWidget, QTableWidgetItem, QSpacerItem, QSpinBox, QTabWidget, QTableWidget,
QVBoxLayout, QWidget) QTableWidgetItem, QVBoxLayout, QWidget)
class Ui_SettingsDialog(object): class Ui_SettingsDialog(object):
def setupUi(self, SettingsDialog): def setupUi(self, SettingsDialog):
@@ -104,6 +104,34 @@ class Ui_SettingsDialog(object):
self.generalLayout.addItem(self.generalSpacer) self.generalLayout.addItem(self.generalSpacer)
self.settingsTabs.addTab(self.generalTab, "") 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 = QWidget()
self.simulationTab.setObjectName(u"simulationTab") self.simulationTab.setObjectName(u"simulationTab")
self.simulationTabLayout = QVBoxLayout(self.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.iconGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Icon grid size:", None))
self.iconGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", 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.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.openModelicaGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"OpenModelica", None))
self.openModelicaPathLabel.setText(QCoreApplication.translate("SettingsDialog", u"OpenModelica executable:", 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)) 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. # This is the intentional code-level styling point for every port/connection type.
CONNECTION_STYLES: dict[str, ConnectionStyle] = { CONNECTION_STYLES: dict[str, ConnectionStyle] = {
"signal": 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.name_label: NameLabelItem | None = None
self.sync_name_label(connection) 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: def sync_name_label(self, connection: Connection) -> None:
visible = bool(connection.properties.get("showName", False)) visible = bool(connection.properties.get("showName", False))
if not visible: if not visible:

View File

@@ -86,6 +86,55 @@
</item> </item>
</layout> </layout>
</widget> </widget>
<widget class="QWidget" name="bondGraphTab">
<attribute name="title">
<string>Bond graph</string>
</attribute>
<layout class="QVBoxLayout" name="bondGraphLayout">
<item>
<widget class="QGroupBox" name="causalityGroupBox">
<property name="title">
<string>Causality</string>
</property>
<layout class="QVBoxLayout" name="causalityLayout">
<item>
<widget class="QCheckBox" name="automaticCausalityCheckBox">
<property name="text">
<string>Infer causality when connections are added or removed</string>
</property>
<property name="checked">
<bool>true</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="automaticCausalityHintLabel">
<property name="text">
<string>When disabled, displayed causalities are updated only when compiling, exporting, or running the model.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item>
<spacer name="bondGraphSpacer">
<property name="orientation">
<enum>Qt::Orientation::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<widget class="QWidget" name="simulationTab"> <widget class="QWidget" name="simulationTab">
<attribute name="title"><string>Simulation</string></attribute> <attribute name="title"><string>Simulation</string></attribute>
<layout class="QVBoxLayout" name="simulationTabLayout"> <layout class="QVBoxLayout" name="simulationTabLayout">

View File

@@ -216,7 +216,7 @@
"description": "", "description": "",
"orientation": "indifferent", "orientation": "indifferent",
"domain": "power", "domain": "power",
"causality": "indifferent" "causality": "single flow in"
} }
] ]
}, },
@@ -451,11 +451,431 @@
"initialEquations": "" "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": [ "connections": [
{ {
"id": "4b09ba21-baa0-4db7-9431-4c919478bce4", "id": "55c04603-2ae5-44d2-b3f3-493a73e88d73",
"source": { "source": {
"block": "063a6341-cb18-4bfe-a1e4-00957a88a874", "block": "063a6341-cb18-4bfe-a1e4-00957a88a874",
"port": "port-2e1d884f" "port": "port-2e1d884f"
@@ -469,16 +889,16 @@
"waypoints": [] "waypoints": []
}, },
"type": "power", "type": "power",
"causality": "none" "causality": "target"
}, },
{ {
"id": "a73a99a1-54e2-461d-a9a7-2d6d11d500f4", "id": "5e7f801a-eb4a-46d2-9279-b4e81e1e011b",
"source": { "source": {
"block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6", "block": "140ae14b-9dbb-4756-aa25-29101ef2a99e",
"port": "port-3e53bf80" "port": "port-3e53bf80"
}, },
"target": { "target": {
"block": "ea95e8ee-7f2d-4e14-a65d-d56bec5b4eb2", "block": "9c1aa152-f9fb-459a-9911-e90dd5a9beb3",
"port": "port-2e1d884f" "port": "port-2e1d884f"
}, },
"name": "", "name": "",
@@ -486,10 +906,27 @@
"waypoints": [] "waypoints": []
}, },
"type": "power", "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": { "source": {
"block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6", "block": "bfa506d8-ee41-4bab-ae19-a97fd67bfcf6",
"port": "port-3e53bf80" "port": "port-3e53bf80"
@@ -503,7 +940,58 @@
"waypoints": [] "waypoints": []
}, },
"type": "power", "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": [], "annotations": [],