Start with causality inference

This commit is contained in:
2026-07-22 13:20:49 +02:00
parent 09248421d0
commit 32154386e8
17 changed files with 493 additions and 266 deletions

View File

@@ -311,24 +311,21 @@ class DeleteSelectionCommand(QUndoCommand):
owner_id: str | None,
blocks: dict[str, Component],
connections: dict[str, Connection],
inputs: list[Port],
outputs: list[Port],
ports: list[Port],
) -> None:
super().__init__("Delete selection")
self.controller = controller
self.owner_id = owner_id
self.blocks = blocks
self.connections = connections
self.inputs = inputs
self.outputs = outputs
self.ports = ports
def redo(self) -> None:
self.controller._delete_items(
self.owner_id,
set(self.blocks),
set(self.connections),
{port.id for port in self.inputs},
{port.id for port in self.outputs},
{port.id for port in self.ports},
)
def undo(self) -> None:
@@ -336,8 +333,7 @@ class DeleteSelectionCommand(QUndoCommand):
self.owner_id,
self.blocks,
self.connections,
self.inputs,
self.outputs,
self.ports,
)
@@ -361,7 +357,6 @@ class PasteSelectionCommand(QUndoCommand):
self.blocks,
self.connections,
[],
[],
)
def undo(self) -> None:
@@ -370,7 +365,6 @@ class PasteSelectionCommand(QUndoCommand):
set(self.blocks),
set(self.connections),
set(),
set(),
)

View File

@@ -40,6 +40,7 @@ from bedit.core.model import (
Port,
clone_component,
)
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
@@ -307,6 +308,7 @@ class DocumentController(QObject):
properties={
"waypoints": [{"x": point.x(), "y": point.y()} for point in (waypoints or [])],
},
type=source_port.type,
)
self.undo_stack.push(AddConnectionCommand(self, self.active_component_id, connection))
return connection.id
@@ -324,6 +326,8 @@ class DocumentController(QObject):
if original is None:
raise ValueError("The connection no longer exists")
port_type = self.connection_port_type(original)
if port_type == "power":
raise ValueError("Power bond connections cannot contain junctions")
junction = Junction(str(uuid4()), position.x(), position.y(), port_type)
first_properties = deepcopy(original.properties)
first_properties["waypoints"] = [
@@ -338,6 +342,8 @@ class DocumentController(QObject):
Endpoint(junction=junction.id),
original.name,
first_properties,
original.type,
original.causality,
)
second = Connection(
str(uuid4()),
@@ -345,6 +351,8 @@ class DocumentController(QObject):
original.target,
"",
second_properties,
original.type,
original.causality,
)
self.undo_stack.push(
SplitConnectionCommand(
@@ -442,12 +450,14 @@ class DocumentController(QObject):
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before composing")
self._infer_active_graph_causality(component)
self.simulation.compose(component.to_dict())
def compose_active_graph_source(self) -> tuple[str, str]:
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before exporting a model")
self._infer_active_graph_causality(component)
return self.simulation.compose_source(component.to_dict())
def run_simulation(
@@ -460,6 +470,7 @@ class DocumentController(QObject):
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before running a simulation")
self._infer_active_graph_causality(component)
self.simulation.run_simulation(
component.to_dict(),
progress_callback,
@@ -468,6 +479,34 @@ class DocumentController(QObject):
error_callback,
)
def _infer_active_graph_causality(self, component: Component) -> None:
"""Infer causality and copy the derived values into the live model."""
inferred = infer_causality(component.to_dict())
causalities: dict[str, str] = {}
def collect(serialized_component: dict) -> None:
graph = serialized_component.get("implementation", {}).get("graph", {})
for connection in graph.get("connections", []):
causalities[str(connection["id"])] = str(
connection.get("causality", "none")
)
for block in graph.get("blocks", []):
collect(block)
collect(inferred)
changed = False
for nested_component in self._component_subtree(component):
for connection in nested_component.graph.connections.values():
causality = causalities.get(connection.id, "none")
if connection.causality != causality:
connection.causality = causality
changed = True
if changed:
if self.document is not None:
self.document.validate()
self.documentReset.emit()
def set_route_waypoints(self, item_kind: str, item_id: str, waypoints: list[QPointF]) -> None:
item = (
self.active_graph.connections
@@ -577,25 +616,26 @@ class DocumentController(QObject):
allows_multiple_connections=role == "source",
)
if endpoint.interface is not None:
ports = owner.inputs if role == "source" else owner.outputs
orientations = (
{"input", "indifferent"}
if role == "source"
else {"output", "indifferent"}
)
ports = [
*ports,
*(
port
for port in owner.inputs
if port.orientation == "indifferent" and port not in ports
),
port for port in owner.ports if port.orientation in orientations
]
else:
component = owner.graph.blocks.get(endpoint.block or "")
if component is None:
return None
ports = component.outputs if role == "source" else component.inputs
if role == "source":
ports = [
*ports,
*(port for port in component.inputs if port.orientation == "indifferent"),
]
orientations = (
{"output", "indifferent"}
if role == "source"
else {"input", "indifferent"}
)
ports = [
port for port in component.ports if port.orientation in orientations
]
return next(
(port for port in ports if port.id == (endpoint.interface or endpoint.port)), None
)
@@ -619,12 +659,13 @@ class DocumentController(QObject):
component = self.active_component
if component is None or component.implementation_kind != "graph":
raise ValueError("Open a graph component before adding an interface")
ports = component.inputs if direction == "input" else component.outputs
ports = [port for port in component.ports if port.orientation == direction]
port = Port(
id=f"{direction}-{uuid4().hex[:8]}",
name=f"{direction.title()} {len(ports) + 1}",
x=position.x(),
y=position.y(),
orientation=direction,
)
self.undo_stack.push(AddInterfacePortCommand(self, component.id, direction, port))
return port.id
@@ -639,7 +680,7 @@ class DocumentController(QObject):
owner = self.active_component
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
port = next((port for port in owner.ports if port.id == port_id), None)
if port is not None and port.name != name:
self.undo_stack.push(
RenameInterfacePortCommand(self, owner.id, port_id, port.name, name)
@@ -670,8 +711,7 @@ class DocumentController(QObject):
def replace_active_text_definition(
self,
inputs: list[Port],
outputs: list[Port],
ports: list[Port],
declarations: str,
initial_equations: str,
equations: str,
@@ -680,14 +720,17 @@ class DocumentController(QObject):
component = self.active_component
if component is None or component.implementation_kind != "text":
raise ValueError("Only text-defined components can be edited here")
input_ids = [port.id for port in inputs]
output_ids = [port.id for port in outputs]
source_ids = output_ids + [
port.id for port in inputs if port.orientation == "indifferent"
]
if len(set(input_ids)) != len(input_ids) or len(set(output_ids)) != len(output_ids):
raise ValueError("Input and output IDs must be unique")
if any(not port.name.strip() for port in (*inputs, *outputs)):
port_ids = [port.id for port in ports]
input_ids = {
port.id for port in ports if port.orientation in {"input", "indifferent"}
}
output_ids = {
port.id for port in ports if port.orientation in {"output", "indifferent"}
}
source_ids = output_ids
if len(set(port_ids)) != len(port_ids):
raise ValueError("Port IDs must be unique")
if any(not port.name.strip() for port in ports):
raise ValueError("Every port must have a name")
parameter_ids = [parameter.id for parameter in parameters]
if len(set(parameter_ids)) != len(parameter_ids):
@@ -713,14 +756,12 @@ class DocumentController(QObject):
f"Output {connection.source.port!r} is still connected in the containing graph"
)
old = {
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"ports": [port.to_dict() for port in component.ports],
"source": deepcopy(component.source),
"parameters": [parameter.to_dict() for parameter in component.parameters],
}
new = {
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"ports": [port.to_dict() for port in ports],
"source": {
"equations": equations,
"declarations": declarations,
@@ -731,8 +772,7 @@ class DocumentController(QObject):
if old != new:
candidate = deepcopy(self.document)
candidate_component = candidate.find_component(component.id)
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate_component.ports = deepcopy(ports)
candidate_component.source = deepcopy(new["source"])
candidate_component.parameters = deepcopy(parameters)
candidate.validate()
@@ -743,8 +783,7 @@ class DocumentController(QObject):
component_id: str,
name: str,
icon: Icon,
inputs: list[Port],
outputs: list[Port],
ports: list[Port],
show_subtree: bool,
show_name: bool,
) -> None:
@@ -759,8 +798,7 @@ class DocumentController(QObject):
old = {
"name": component.name,
"icon": component.icon.to_dict(),
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"ports": [port.to_dict() for port in component.ports],
"show_subtree": component.show_subtree_in_library,
"properties": deepcopy(component.properties),
}
@@ -772,8 +810,7 @@ class DocumentController(QObject):
new = {
"name": name,
"icon": icon.to_dict(),
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"ports": [port.to_dict() for port in ports],
"show_subtree": show_subtree,
"properties": properties,
}
@@ -782,8 +819,7 @@ class DocumentController(QObject):
candidate_component = candidate.find_component(component_id)
candidate_component.name = name
candidate_component.icon = Icon.from_dict(icon.to_dict())
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate_component.ports = deepcopy(ports)
candidate_component.properties = deepcopy(properties)
candidate.validate()
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
@@ -853,19 +889,19 @@ class DocumentController(QObject):
)
)
def edit_component_ports(
self, component_id: str, inputs: list[Port], outputs: list[Port]
) -> None:
def edit_component_ports(self, component_id: str, ports: list[Port]) -> None:
if self.document is None:
return
component = self.document.find_component(component_id)
if component is None:
return
input_ids = {port.id for port in inputs}
output_ids = {port.id for port in outputs}
source_ids = output_ids | {
port.id for port in inputs if port.orientation == "indifferent"
input_ids = {
port.id for port in ports if port.orientation in {"input", "indifferent"}
}
output_ids = {
port.id for port in ports if port.orientation in {"output", "indifferent"}
}
source_ids = output_ids
parent = self.document.find_parent(component_id)
if parent is not None:
for connection in parent.graph.connections.values():
@@ -886,21 +922,18 @@ class DocumentController(QObject):
raise ValueError("An interface output cannot be removed while connected")
candidate = deepcopy(self.document)
candidate_component = candidate.find_component(component_id)
candidate_component.inputs = deepcopy(inputs)
candidate_component.outputs = deepcopy(outputs)
candidate_component.ports = deepcopy(ports)
candidate.validate()
old = {
"name": component.name,
"icon": component.icon.to_dict(),
"inputs": [port.to_dict() for port in component.inputs],
"outputs": [port.to_dict() for port in component.outputs],
"ports": [port.to_dict() for port in component.ports],
"show_subtree": component.show_subtree_in_library,
"properties": deepcopy(component.properties),
}
new = {
**old,
"inputs": [port.to_dict() for port in inputs],
"outputs": [port.to_dict() for port in outputs],
"ports": [port.to_dict() for port in ports],
}
if old != new:
self.undo_stack.push(EditComponentAppearanceCommand(self, component_id, old, new))
@@ -930,8 +963,7 @@ class DocumentController(QObject):
self,
block_ids: set[str],
connection_ids: set[str],
input_ids: set[str],
output_ids: set[str],
port_ids: set[str],
) -> None:
component = self.active_component
if component is None or component.implementation_kind != "graph":
@@ -942,8 +974,8 @@ class DocumentController(QObject):
if (
connection.source.block in block_ids
or connection.target.block in block_ids
or connection.source.interface in input_ids
or connection.target.interface in output_ids
or connection.source.interface in port_ids
or connection.target.interface in port_ids
):
all_connection_ids.add(connection.id)
blocks = {
@@ -954,9 +986,8 @@ class DocumentController(QObject):
for connection_id in all_connection_ids
if connection_id in graph.connections
}
inputs = [port for port in component.inputs if port.id in input_ids]
outputs = [port for port in component.outputs if port.id in output_ids]
if not (blocks or connections or inputs or outputs):
ports = [port for port in component.ports if port.id in port_ids]
if not (blocks or connections or ports):
return
self.undo_stack.push(
DeleteSelectionCommand(
@@ -964,8 +995,7 @@ class DocumentController(QObject):
component.id,
blocks,
connections,
inputs,
outputs,
ports,
)
)
@@ -1003,6 +1033,8 @@ class DocumentController(QObject):
target=Endpoint(block=id_map[source.target.block], port=source.target.port),
name=source.name,
properties=properties,
type=source.type,
causality=source.causality,
)
connections[connection.id] = connection
if blocks:
@@ -1063,6 +1095,8 @@ class DocumentController(QObject):
),
name=source.name,
properties=properties,
type=source.type,
causality=source.causality,
)
connections[connection.id] = connection
@@ -1278,9 +1312,9 @@ class DocumentController(QObject):
owner = self.document.find_component(owner_id)
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
if all(existing.id != port.id for existing in ports):
ports.append(port)
port.orientation = direction
if all(existing.id != port.id for existing in owner.ports):
owner.ports.append(port)
self.interfaceChanged.emit()
self.documentReset.emit()
@@ -1290,8 +1324,7 @@ class DocumentController(QObject):
owner = self.document.find_component(owner_id)
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
ports[:] = [port for port in ports if port.id != port_id]
owner.ports[:] = [port for port in owner.ports if port.id != port_id]
self.interfaceChanged.emit()
self.documentReset.emit()
@@ -1301,7 +1334,7 @@ class DocumentController(QObject):
owner = self.document.find_component(owner_id)
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
port = next((port for port in owner.ports if port.id == port_id), None)
if port is not None:
port.x, port.y = position.x(), position.y()
self.interfaceChanged.emit()
@@ -1311,7 +1344,7 @@ class DocumentController(QObject):
owner = self.document.find_component(owner_id) if self.document else None
if owner is None:
return
port = next((p for p in (*owner.inputs, *owner.outputs) if p.id == port_id), None)
port = next((port for port in owner.ports if port.id == port_id), None)
if port is not None:
port.name = name
self.interfaceChanged.emit()
@@ -1342,8 +1375,7 @@ class DocumentController(QObject):
return
component.name = values["name"]
component.icon = Icon.from_dict(values["icon"])
component.inputs = [Port.from_dict(port) for port in values["inputs"]]
component.outputs = [Port.from_dict(port) for port in values["outputs"]]
component.ports = [Port.from_dict(port) for port in values["ports"]]
component.show_subtree_in_library = values["show_subtree"]
component.properties = deepcopy(values["properties"])
self.documentReset.emit()
@@ -1374,8 +1406,7 @@ class DocumentController(QObject):
owner_id: str | None,
block_ids: set[str],
connection_ids: set[str],
input_ids: set[str],
output_ids: set[str],
port_ids: set[str],
) -> None:
if self.document is None:
return
@@ -1398,8 +1429,7 @@ class DocumentController(QObject):
owner.graph.blocks.pop(block_id, None)
for connection_id in connection_ids:
owner.graph.connections.pop(connection_id, None)
owner.inputs[:] = [port for port in owner.inputs if port.id not in input_ids]
owner.outputs[:] = [port for port in owner.outputs if port.id not in output_ids]
owner.ports[:] = [port for port in owner.ports if port.id not in port_ids]
if active_was_deleted:
self.active_component_id = owner_id or next(iter(self.document.roots), None)
self.activeGraphChanged.emit()
@@ -1410,8 +1440,7 @@ class DocumentController(QObject):
owner_id: str | None,
blocks: dict[str, Component],
connections: dict[str, Connection],
inputs: list[Port],
outputs: list[Port],
ports: list[Port],
) -> None:
if self.document is None:
return
@@ -1423,10 +1452,8 @@ class DocumentController(QObject):
return
owner.graph.blocks.update(blocks)
owner.graph.connections.update(connections)
existing_inputs = {port.id for port in owner.inputs}
existing_outputs = {port.id for port in owner.outputs}
owner.inputs.extend(port for port in inputs if port.id not in existing_inputs)
owner.outputs.extend(port for port in outputs if port.id not in existing_outputs)
existing_ports = {port.id for port in owner.ports}
owner.ports.extend(port for port in ports if port.id not in existing_ports)
self.documentReset.emit()
@staticmethod
@@ -1441,8 +1468,7 @@ class DocumentController(QObject):
component = self.document.find_component(component_id)
if component is None:
return
component.inputs = [Port.from_dict(item) for item in values["inputs"]]
component.outputs = [Port.from_dict(item) for item in values["outputs"]]
component.ports = [Port.from_dict(item) for item in values["ports"]]
component.source = deepcopy(values["source"])
component.parameters = [
Parameter.from_dict(item) for item in values.get("parameters", [])