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

@@ -56,8 +56,9 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
anchors may change without changing IDs. anchors may change without changing IDs.
- Parameters belong to `Component` rather than a particular implementation kind, - Parameters belong to `Component` rather than a particular implementation kind,
so graph and text components share stable-ID name/type/value records. so graph and text components share stable-ID name/type/value records.
- Port orientation is presented as one unified list in the UI, while the model - Components store one ordered `ports` list. Each port's `orientation` field is
indexes inputs and outputs separately for connection semantics. authoritative (`input`, `output`, or power-only `indifferent`); connection and
presentation code derives any directional groupings when needed.
- 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
@@ -72,9 +73,15 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
- Port removal or reorientation must be rejected when it would invalidate an - Port removal or reorientation must be rejected when it would invalidate an
existing connection. existing connection.
- Connections reference port IDs, never port names. - Connections reference port IDs, never port names.
- Connections store their port type and bond-graph causality explicitly.
`core/bond_graph.py` owns causality inference; the simulation service invokes
it before Modelica composition. The controller copies inferred values into the
live model and refreshes the workspace before compile, export, or run.
Causality values are `none`, `source`, `target`, `warn_source`, and `warn_target`.
- Connection junctions are explicit typed graph objects. Splitting a connection - Connection junctions are explicit typed graph objects. Splitting a connection
creates one incoming and one outgoing segment; the junction can source further creates one incoming and one outgoing segment; the junction can source further
branches without overlapping full connection paths. branches without overlapping full connection paths. Power bond connections
cannot contain junctions.
- Graph interaction has separate Pointer and Connect modes. Port hints are only - Graph interaction has separate Pointer and Connect modes. Port hints are only
visible in Connect mode. Connecting two blocks opens the compatible port-pair visible in Connect mode. Connecting two blocks opens the compatible port-pair
chooser; explicit port clicks determine its default selection. Connections chooser; explicit port clicks determine its default selection. Connections
@@ -324,6 +331,7 @@ PYTHONPATH=src python3 -m bedit
``` ```
The installed GUI entry point is `bedit.gui.app:main`. The installed GUI entry point is `bedit.gui.app:main`.
The first non-flag launch argument is opened as the initial document.
## Completion checklist ## Completion checklist

View File

@@ -159,12 +159,12 @@ Every component owns its ports, declarative icon, properties, and child graph:
"name": "My Component", "name": "My Component",
"position": {"x": 0, "y": 0}, "position": {"x": 0, "y": 0},
"interface": { "interface": {
"inputs": [{"id": "in", "name": "Input", "type": "signal", "properties": { "ports": [
"iconPosition": {"x": 0, "y": 40} {"id": "in", "name": "Input", "type": "signal", "orientation": "input",
}}], "properties": {"iconPosition": {"x": 0, "y": 40}}},
"outputs": [{"id": "out", "name": "Output", "type": "signal", "properties": { {"id": "out", "name": "Output", "type": "signal", "orientation": "output",
"iconPosition": {"x": 128, "y": 64} "properties": {"iconPosition": {"x": 128, "y": 64}}}
}}] ]
}, },
"icon": { "icon": {
"size": {"width": 128, "height": 128}, "size": {"width": 128, "height": 128},

View File

@@ -0,0 +1,134 @@
"""Bond-graph analysis hooks.
This module intentionally has no GUI or simulation-engine dependencies. The
causality inference algorithm can grow here without coupling the document model
to OpenModelica or Qt.
"""
from __future__ import annotations
from typing import Any
from bedit.core.application_log import get_logger
log = get_logger(__name__)
def infer_causality(component: dict[str, Any], toplevel: bool = True) -> dict[str, Any]:
"""Infer power-connection causality in a serialized component tree.
This is currently a traversal stub: it ensures every power connection has a
causality value, while preserving causality already supplied by callers.
Future inference rules should assign ``source``, ``target``,
``warn_source``, or ``warn_target`` here and return the same tree.
The input is mutated and returned so the composer receives the inferred
representation without needing a second document conversion.
"""
if toplevel:
log.info("Causality inference")
# Reset causalities
reset_causality(component)
implementation = component.get("implementation", {})
graph = implementation.get("graph") if isinstance(implementation, dict) else None
if not isinstance(graph, dict):
return component
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'
for block in graph.get("blocks", []):
if isinstance(block, dict):
infer_causality(block, False)
return component
def reset_causality(component: dict[str, Any]) -> None:
implementation = component.get("implementation", {})
graph = implementation.get("graph") if isinstance(implementation, dict) else None
if not isinstance(graph, dict):
return
for connection in graph.get("connections", []):
if isinstance(connection, dict) and connection.get("type") == "power":
connection["causality"] = "none"
for block in graph.get("blocks", []):
if isinstance(block, dict):
reset_causality(block)
def build_id_list(graph: dict[str, Any]) -> dict[str, Any]:
"""Index all addressable objects in a component tree by their stable ID."""
id_list: dict[str, Any] = {}
id_kinds: dict[str, str] = {}
def add(item: dict[str, Any], description: str) -> None:
item_id = item.get("id")
if not item_id:
raise ValueError(f"{description} has no ID")
# Port IDs identify a port on a component definition and may therefore
# recur in cloned component instances. Component and junction IDs are
# document objects and must remain globally unique.
if item_id in id_list and not (
description == "port" and id_kinds[item_id] == "port"
):
raise ValueError(f"Duplicate simulation object ID: {item_id}")
id_list[item_id] = item
id_kinds[item_id] = description
def visit(component: dict[str, Any]) -> None:
add(component, "component")
interface = component.get("interface", {})
for port in interface.get("ports", []):
add(port, "port")
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return
nested_graph = implementation.get("graph", {})
for junction in nested_graph.get("junctions", []):
add(junction, "junction")
for block in nested_graph.get("blocks", []):
visit(block)
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
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")
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")
return source_port, target_port

View File

@@ -180,6 +180,8 @@ class Connection:
target: Endpoint target: Endpoint
name: str = "" name: str = ""
properties: dict[str, Any] = field(default_factory=dict) properties: dict[str, Any] = field(default_factory=dict)
type: str = "signal"
causality: str = "none"
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@@ -188,6 +190,8 @@ class Connection:
"target": self.target.to_dict(), "target": self.target.to_dict(),
"name": self.name, "name": self.name,
"properties": self.properties, "properties": self.properties,
"type": self.type,
"causality": self.causality,
} }
@classmethod @classmethod
@@ -198,6 +202,8 @@ class Connection:
target=Endpoint.from_dict(data["target"]), target=Endpoint.from_dict(data["target"]),
name=str(data.get("name", "")), name=str(data.get("name", "")),
properties=dict(data.get("properties", {})), properties=dict(data.get("properties", {})),
type=str(data.get("type", "signal")),
causality=str(data.get("causality", "none")),
) )
@@ -360,8 +366,7 @@ class Component:
x: float = 0.0 x: float = 0.0
y: float = 0.0 y: float = 0.0
rotation: float = 0.0 rotation: float = 0.0
inputs: list[Port] = field(default_factory=list) ports: list[Port] = field(default_factory=list)
outputs: list[Port] = field(default_factory=list)
parameters: list[Parameter] = field(default_factory=list) parameters: list[Parameter] = field(default_factory=list)
icon: Icon = field(default_factory=Icon) icon: Icon = field(default_factory=Icon)
properties: dict[str, Any] = field(default_factory=dict) properties: dict[str, Any] = field(default_factory=dict)
@@ -381,10 +386,7 @@ class Component:
"name": self.name, "name": self.name,
"position": {"x": self.x, "y": self.y}, "position": {"x": self.x, "y": self.y},
"rotation": self.rotation, "rotation": self.rotation,
"interface": { "interface": {"ports": [port.to_dict() for port in self.ports]},
"inputs": [port.to_dict() for port in self.inputs],
"outputs": [port.to_dict() for port in self.outputs],
},
"parameters": [parameter.to_dict() for parameter in self.parameters], "parameters": [parameter.to_dict() for parameter in self.parameters],
"icon": self.icon.to_dict(), "icon": self.icon.to_dict(),
"properties": self.properties, "properties": self.properties,
@@ -427,8 +429,7 @@ class Component:
x=float(position.get("x", 0.0)), x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)), y=float(position.get("y", 0.0)),
rotation=float(data.get("rotation", 0.0)), rotation=float(data.get("rotation", 0.0)),
inputs=[Port.from_dict(item, "input") for item in interface.get("inputs", [])], ports=[Port.from_dict(item) for item in interface.get("ports", [])],
outputs=[Port.from_dict(item, "output") for item in interface.get("outputs", [])],
parameters=[Parameter.from_dict(item) for item in parameters], parameters=[Parameter.from_dict(item) for item in parameters],
icon=Icon.from_dict(data.get("icon")), icon=Icon.from_dict(data.get("icon")),
properties=dict(data.get("properties", {})), properties=dict(data.get("properties", {})),
@@ -532,11 +533,10 @@ class GraphDocument:
raise ValueError( raise ValueError(
f"Component names inside {owner.name!r} must be unique" f"Component names inside {owner.name!r} must be unique"
) )
input_ids = {port.id for port in owner.inputs} port_ids = {port.id for port in owner.ports}
output_ids = {port.id for port in owner.outputs} if len(port_ids) != len(owner.ports):
if len(input_ids) != len(owner.inputs) or len(output_ids) != len(owner.outputs):
raise ValueError(f"Component {owner.name} contains duplicate port IDs") raise ValueError(f"Component {owner.name} contains duplicate port IDs")
for port in (*owner.inputs, *owner.outputs): for port in owner.ports:
PortTypeRegistry.get(port.type) PortTypeRegistry.get(port.type)
if port.orientation not in {"input", "output", "indifferent"}: if port.orientation not in {"input", "output", "indifferent"}:
raise ValueError(f"Port {port.name!r} has an invalid orientation") raise ValueError(f"Port {port.name!r} has an invalid orientation")
@@ -553,8 +553,18 @@ class GraphDocument:
raise ValueError(f"Port {port.name!r} has invalid dimensions") raise ValueError(f"Port {port.name!r} has invalid dimensions")
for junction in owner.graph.junctions.values(): for junction in owner.graph.junctions.values():
PortTypeRegistry.get(junction.type) PortTypeRegistry.get(junction.type)
if junction.type == "power":
raise ValueError("Power bond connections cannot contain junctions")
endpoint_counts: dict[tuple[str, str, str], int] = {} endpoint_counts: dict[tuple[str, str, str], int] = {}
for connection in owner.graph.connections.values(): for connection in owner.graph.connections.values():
if connection.causality not in {
"none",
"source",
"target",
"warn_source",
"warn_target",
}:
raise ValueError(f"Connection {connection.id} has invalid causality")
if connection.source.junction is not None: if connection.source.junction is not None:
junction = owner.graph.junctions.get(connection.source.junction) junction = owner.graph.junctions.get(connection.source.junction)
if junction is None: if junction is None:
@@ -563,17 +573,25 @@ class GraphDocument:
) )
source_port = Port(junction.id, "Junction", type=junction.type) source_port = Port(junction.id, "Junction", type=junction.type)
elif connection.source.interface is not None: elif connection.source.interface is not None:
if connection.source.interface not in input_ids: source_ports = [
port
for port in owner.ports
if port.orientation in {"input", "indifferent"}
]
if connection.source.interface not in {port.id for port in source_ports}:
raise ValueError(f"Connection {connection.id} uses an unknown interface input") raise ValueError(f"Connection {connection.id} uses an unknown interface input")
source_port = next(p for p in owner.inputs if p.id == connection.source.interface) source_port = next(
port for port in source_ports if port.id == connection.source.interface
)
else: else:
source = owner.graph.blocks.get(connection.source.block or "") source = owner.graph.blocks.get(connection.source.block or "")
source_ports = ( source_ports = (
[] []
if source is None if source is None
else [ else [
*source.outputs, port
*(p for p in source.inputs if p.orientation == "indifferent"), for port in source.ports
if port.orientation in {"output", "indifferent"}
] ]
) )
if source is None or connection.source.port not in {p.id for p in source_ports}: if source is None or connection.source.port not in {p.id for p in source_ports}:
@@ -592,21 +610,34 @@ class GraphDocument:
allows_multiple_connections=False, allows_multiple_connections=False,
) )
elif connection.target.interface is not None: elif connection.target.interface is not None:
indifferent_ids = { target_ports = [
port.id for port in owner.inputs if port.orientation == "indifferent" port
} for port in owner.ports
if connection.target.interface not in output_ids | indifferent_ids: if port.orientation in {"output", "indifferent"}
]
if connection.target.interface not in {port.id for port in target_ports}:
raise ValueError(f"Connection {connection.id} uses an unknown interface output") raise ValueError(f"Connection {connection.id} uses an unknown interface output")
target_port = next( target_port = next(
p port for port in target_ports if port.id == connection.target.interface
for p in (*owner.outputs, *owner.inputs)
if p.id == connection.target.interface
) )
else: else:
target = owner.graph.blocks.get(connection.target.block or "") target = owner.graph.blocks.get(connection.target.block or "")
if target is None or connection.target.port not in {p.id for p in target.inputs}: target_ports = (
[]
if target is None
else [
port
for port in target.ports
if port.orientation in {"input", "indifferent"}
]
)
if target is None or connection.target.port not in {
port.id for port in target_ports
}:
raise ValueError(f"Connection {connection.id} uses an unknown block input") raise ValueError(f"Connection {connection.id} uses an unknown block input")
target_port = next(p for p in target.inputs if p.id == connection.target.port) target_port = next(
port for port in target_ports if port.id == connection.target.port
)
if not PortTypeRegistry.compatible( if not PortTypeRegistry.compatible(
source_port.type, source_port.type,
target_port.type, target_port.type,
@@ -614,6 +645,9 @@ class GraphDocument:
target_port.domain, target_port.domain,
): ):
raise ValueError(f"Connection {connection.id} joins incompatible port types") raise ValueError(f"Connection {connection.id} joins incompatible port types")
# The endpoint ports remain authoritative. This also upgrades older
# documents whose connections predate the explicit type field.
connection.type = source_port.type
source_key = ( source_key = (
"source-junction" "source-junction"
if connection.source.junction is not None if connection.source.junction is not None
@@ -676,6 +710,8 @@ def clone_component(source: Component) -> Component:
remap(connection.target), remap(connection.target),
connection.name, connection.name,
deepcopy(connection.properties), deepcopy(connection.properties),
connection.type,
connection.causality,
) )
for connection in current.graph.connections.values() for connection in current.graph.connections.values()
for new_id in [str(uuid4())] for new_id in [str(uuid4())]
@@ -708,14 +744,7 @@ def clone_component(source: Component) -> Component:
name=current.name, name=current.name,
x=current.x, x=current.x,
y=current.y, y=current.y,
inputs=[ ports=[Port.from_dict(port.to_dict()) for port in current.ports],
Port.from_dict(port.to_dict(), "input")
for port in current.inputs
],
outputs=[
Port.from_dict(port.to_dict(), "output")
for port in current.outputs
],
parameters=deepcopy(current.parameters), parameters=deepcopy(current.parameters),
icon=Icon.from_dict(current.icon.to_dict()), icon=Icon.from_dict(current.icon.to_dict()),
properties=deepcopy(current.properties), properties=deepcopy(current.properties),

View File

@@ -59,9 +59,7 @@ def build_id_list(graph: dict[str, Any]) -> dict[str, Any]:
def visit(component: dict[str, Any]) -> None: def visit(component: dict[str, Any]) -> None:
add(component, "component") add(component, "component")
interface = component.get("interface", {}) interface = component.get("interface", {})
for port in interface.get("inputs", []): for port in interface.get("ports", []):
add(port, "port")
for port in interface.get("outputs", []):
add(port, "port") add(port, "port")
implementation = component.get("implementation", {}) implementation = component.get("implementation", {})
@@ -110,10 +108,9 @@ def emit_model(
) )
interface = graph.get("interface", {}) interface = graph.get("interface", {})
for port in interface.get("inputs", []): for port in interface.get("ports", []):
lines.append(_port_declaration(port, "input", indent + 1, macros)) direction = "output" if port.get("orientation") == "output" else "input"
for port in interface.get("outputs", []): lines.append(_port_declaration(port, direction, indent + 1, macros))
lines.append(_port_declaration(port, "output", indent + 1, macros))
for parameter in graph.get("parameters", []): for parameter in graph.get("parameters", []):
parameter_type = modelica_type(parameter.get("type", "real")) parameter_type = modelica_type(parameter.get("type", "real"))
@@ -332,7 +329,7 @@ def _port_count_macros(
) -> dict[str, str]: ) -> dict[str, str]:
macros: dict[str, str] = {} macros: dict[str, str] = {}
interface = component.get("interface", {}) interface = component.get("interface", {})
for port in (*interface.get("inputs", []), *interface.get("outputs", [])): for port in interface.get("ports", []):
if port.get("multipleConnections", False): if port.get("multipleConnections", False):
macros[f"{identifier(port['name'])}_N"] = str( macros[f"{identifier(port['name'])}_N"] = str(
connection_counts.get(port["id"], 0) connection_counts.get(port["id"], 0)
@@ -354,8 +351,7 @@ def expand_bevalues(text: str, values: dict[str, str]) -> str:
def _find_port(component: dict[str, Any], port_id: str) -> dict[str, Any]: def _find_port(component: dict[str, Any], port_id: str) -> dict[str, Any]:
interface = component.get("interface", {}) interface = component.get("interface", {})
ports = [*interface.get("inputs", []), *interface.get("outputs", [])] for port in interface.get("ports", []):
for port in ports:
if port.get("id") == port_id: if port.get("id") == port_id:
return port return port
raise ValueError( raise ValueError(

View File

@@ -2,6 +2,7 @@ from collections.abc import Callable
from typing import Any from typing import Any
from bedit.core.application_log import get_logger from bedit.core.application_log import get_logger
from bedit.core.bond_graph import infer_causality
from bedit.core.simulation.composer import compose_graph from bedit.core.simulation.composer import compose_graph
from bedit.core.simulation.openmodelica import ( from bedit.core.simulation.openmodelica import (
ErrorCallback, ErrorCallback,
@@ -87,6 +88,7 @@ class Simulation:
def compose_source(self, graph: dict[str, Any]) -> tuple[str, str]: def compose_source(self, graph: dict[str, Any]) -> tuple[str, str]:
"""Compose Modelica source without asking OpenModelica to build it.""" """Compose Modelica source without asking OpenModelica to build it."""
infer_causality(graph)
result = compose_graph(graph) result = compose_graph(graph)
self.last_composition_input = result.graph self.last_composition_input = result.graph
self.id_list = result.objects_by_id self.id_list = result.objects_by_id

View File

@@ -1,4 +1,6 @@
import sys import sys
from collections.abc import Sequence
from pathlib import Path
from PySide6.QtCore import QCoreApplication, Qt from PySide6.QtCore import QCoreApplication, Qt
from PySide6.QtGui import QColor, QPalette from PySide6.QtGui import QColor, QPalette
@@ -38,14 +40,26 @@ def apply_light_theme(app: QApplication) -> None:
app.setPalette(palette) app.setPalette(palette)
def main() -> int: def _document_argument(arguments: Sequence[str]) -> Path | None:
"""Return the first positional application argument, if present."""
return next(
(Path(argument) for argument in arguments[1:] if not argument.startswith("-")),
None,
)
def main(argv: Sequence[str] | None = None) -> int:
QCoreApplication.setApplicationName("BEdit") QCoreApplication.setApplicationName("BEdit")
QCoreApplication.setOrganizationName("BEdit") QCoreApplication.setOrganizationName("BEdit")
QCoreApplication.setApplicationVersion("0.1.0") QCoreApplication.setApplicationVersion("0.1.0")
app = QApplication(sys.argv) app = QApplication(list(sys.argv if argv is None else argv))
app.setApplicationDisplayName("BEdit") app.setApplicationDisplayName("BEdit")
apply_light_theme(app) apply_light_theme(app)
window = MainWindow() window = MainWindow()
document_path = _document_argument(app.arguments())
if document_path is not None:
window.open_document_path(document_path, check_unsaved=False)
window.show() window.show()
return app.exec() return app.exec()

View File

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

View File

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

View File

@@ -12,8 +12,7 @@ class ComponentOptionsDialog(QDialog):
self.ui.setupUi(self) self.ui.setupUi(self)
self.component = component self.component = component
self.edited_icon = component.icon self.edited_icon = component.icon
self.edited_inputs = component.inputs self.edited_ports = component.ports
self.edited_outputs = component.outputs
self.ui.nameEdit.setText(component.name) self.ui.nameEdit.setText(component.name)
self.ui.editIconButton.clicked.connect(self.edit_icon) self.ui.editIconButton.clicked.connect(self.edit_icon)
self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library) self.ui.showSubtreeCheckBox.setChecked(component.show_subtree_in_library)
@@ -22,13 +21,11 @@ class ComponentOptionsDialog(QDialog):
def edit_icon(self) -> None: def edit_icon(self) -> None:
working = Component.from_dict(self.component.to_dict()) working = Component.from_dict(self.component.to_dict())
working.icon = self.edited_icon working.icon = self.edited_icon
working.inputs = self.edited_inputs working.ports = self.edited_ports
working.outputs = self.edited_outputs
dialog = IconEditorDialog(working, self) dialog = IconEditorDialog(working, self)
if dialog.exec() == dialog.DialogCode.Accepted: if dialog.exec() == dialog.DialogCode.Accepted:
self.edited_icon = dialog.icon self.edited_icon = dialog.icon
self.edited_inputs = dialog.inputs self.edited_ports = dialog.ports
self.edited_outputs = dialog.outputs
def accept(self) -> None: def accept(self) -> None:
if not self.ui.nameEdit.text().strip(): if not self.ui.nameEdit.text().strip():

View File

@@ -14,13 +14,6 @@ from bedit.gui.generated.ui_port_options_dialog import Ui_PortOptionsDialog
PORT_ROLE = Qt.ItemDataRole.UserRole PORT_ROLE = Qt.ItemDataRole.UserRole
def _oriented_copy(port: Port, fallback: str) -> Port:
copied = deepcopy(port)
if copied.orientation != "indifferent":
copied.orientation = fallback
return copied
class PortOptionsDialog(QDialog): class PortOptionsDialog(QDialog):
"""Unified editor for a component's typed, oriented ports.""" """Unified editor for a component's typed, oriented ports."""
@@ -32,10 +25,7 @@ class PortOptionsDialog(QDialog):
self.ui.setupUi(self) self.ui.setupUi(self)
self.setWindowTitle(f"Port Options — {component.name}") self.setWindowTitle(f"Port Options — {component.name}")
self.read_only = read_only self.read_only = read_only
self.ports: list[tuple[Port, str]] = [ self.ports = deepcopy(component.ports)
*((_oriented_copy(port, "input"), port.orientation or "input") for port in component.inputs),
*((_oriented_copy(port, "output"), "output") for port in component.outputs),
]
self._loading = False self._loading = False
self.ui.typeCombo.clear() self.ui.typeCombo.clear()
for port_type in PortTypeRegistry.all(): for port_type in PortTypeRegistry.all():
@@ -77,22 +67,10 @@ class PortOptionsDialog(QDialog):
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).hide() self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).hide()
self._rebuild_list(0 if self.ports else -1) self._rebuild_list(0 if self.ports else -1)
@property
def inputs(self) -> list[Port]:
return [
port
for port, orientation in self.ports
if orientation in {"input", "indifferent"}
]
@property
def outputs(self) -> list[Port]:
return [port for port, orientation in self.ports if orientation == "output"]
def _rebuild_list(self, row: int = -1) -> None: def _rebuild_list(self, row: int = -1) -> None:
self.ui.portList.clear() self.ui.portList.clear()
for port, orientation in self.ports: for port in self.ports:
item = QListWidgetItem(f"{port.name} [{orientation}, {port.type}]") item = QListWidgetItem(f"{port.name} [{port.orientation}, {port.type}]")
item.setData(PORT_ROLE, port.id) item.setData(PORT_ROLE, port.id)
self.ui.portList.addItem(item) self.ui.portList.addItem(item)
self.ui.portList.setCurrentRow(min(row, len(self.ports) - 1)) self.ui.portList.setCurrentRow(min(row, len(self.ports) - 1))
@@ -102,10 +80,12 @@ class PortOptionsDialog(QDialog):
self._loading = True self._loading = True
enabled = 0 <= row < len(self.ports) enabled = 0 <= row < len(self.ports)
if enabled: if enabled:
port, orientation = self.ports[row] port = self.ports[row]
self.ui.nameEdit.setText(port.name) self.ui.nameEdit.setText(port.name)
self.ui.typeCombo.setCurrentIndex(self.ui.typeCombo.findData(port.type)) self.ui.typeCombo.setCurrentIndex(self.ui.typeCombo.findData(port.type))
self.ui.orientationCombo.setCurrentIndex(self.ui.orientationCombo.findData(orientation)) self.ui.orientationCombo.setCurrentIndex(
self.ui.orientationCombo.findData(port.orientation)
)
self.ui.multipleConnectionsCheckBox.setChecked( self.ui.multipleConnectionsCheckBox.setChecked(
port.allows_multiple_connections port.allows_multiple_connections
) )
@@ -161,7 +141,7 @@ class PortOptionsDialog(QDialog):
row = self.ui.portList.currentRow() row = self.ui.portList.currentRow()
if self._loading or not (0 <= row < len(self.ports)): if self._loading or not (0 <= row < len(self.ports)):
return return
port, _orientation = self.ports[row] port = self.ports[row]
port.name = self.ui.nameEdit.text() port.name = self.ui.nameEdit.text()
port.type = self.ui.typeCombo.currentData() port.type = self.ui.typeCombo.currentData()
port.allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked() port.allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked()
@@ -179,18 +159,12 @@ class PortOptionsDialog(QDialog):
) )
orientation = self.ui.orientationCombo.currentData() orientation = self.ui.orientationCombo.currentData()
port.orientation = orientation port.orientation = orientation
self.ports[row] = (port, orientation) self.ui.portList.item(row).setText(f"{port.name} [{port.orientation}, {port.type}]")
self.ui.portList.item(row).setText(
f"{port.name} [{self.ports[row][1]}, {port.type}]"
)
self.edited.emit() self.edited.emit()
def set_ports(self, inputs: list[Port], outputs: list[Port]) -> None: def set_ports(self, ports: list[Port]) -> None:
self._loading = True self._loading = True
self.ports = [ self.ports = deepcopy(ports)
*((_oriented_copy(port, "input"), port.orientation or "input") for port in inputs),
*((_oriented_copy(port, "output"), "output") for port in outputs),
]
self._loading = False self._loading = False
self._rebuild_list(0 if self.ports else -1) self._rebuild_list(0 if self.ports else -1)
@@ -201,7 +175,7 @@ class PortOptionsDialog(QDialog):
properties={"iconPosition": {"x": 0.0, "y": 0.0}}, properties={"iconPosition": {"x": 0.0, "y": 0.0}},
type="signal", type="signal",
) )
self.ports.append((port, "input")) self.ports.append(port)
self._rebuild_list(len(self.ports) - 1) self._rebuild_list(len(self.ports) - 1)
self.ui.nameEdit.selectAll() self.ui.nameEdit.selectAll()
self.ui.nameEdit.setFocus() self.ui.nameEdit.setFocus()
@@ -216,7 +190,7 @@ class PortOptionsDialog(QDialog):
def accept(self) -> None: def accept(self) -> None:
self._store_current() self._store_current()
if any(not port.name.strip() for port, _orientation in self.ports): if any(not port.name.strip() for port in self.ports):
QMessageBox.warning(self, "Invalid port", "Every port must have a name.") QMessageBox.warning(self, "Invalid port", "Every port must have a name.")
return return
super().accept() super().accept()

View File

@@ -43,8 +43,8 @@ class TextDefinitionEditor(QWidget):
return self.ui.initialEquationsEdit.toPlainText() return self.ui.initialEquationsEdit.toPlainText()
@property @property
def ports(self) -> tuple[list[Port], list[Port]]: def ports(self) -> list[Port]:
return self.ui.portEditor.inputs, self.ui.portEditor.outputs return self.ui.portEditor.ports
@property @property
def parameters(self) -> list[Parameter]: def parameters(self) -> list[Parameter]:
@@ -55,17 +55,16 @@ class TextDefinitionEditor(QWidget):
declarations: str, declarations: str,
initial_equations: str, initial_equations: str,
equations: str, equations: str,
inputs: list[Port], ports: list[Port],
outputs: list[Port],
parameters: list[Parameter], parameters: list[Parameter],
) -> None: ) -> None:
self._loading = True self._loading = True
self.ui.declarationsEdit.setPlainText(declarations) self.ui.declarationsEdit.setPlainText(declarations)
self.ui.initialEquationsEdit.setPlainText(initial_equations) self.ui.initialEquationsEdit.setPlainText(initial_equations)
self.ui.equationsEdit.setPlainText(equations) self.ui.equationsEdit.setPlainText(equations)
self.ui.portEditor.set_ports(inputs, outputs) self.ui.portEditor.set_ports(ports)
self.ui.parameterEditor.set_parameters(parameters) self.ui.parameterEditor.set_parameters(parameters)
self._set_editor_symbols(inputs, outputs, parameters) self._set_editor_symbols(ports, parameters)
self._loading = False self._loading = False
self.set_modified(False) self.set_modified(False)
@@ -85,18 +84,20 @@ class TextDefinitionEditor(QWidget):
self._mark_modified() self._mark_modified()
def _refresh_editor_symbols(self) -> None: def _refresh_editor_symbols(self) -> None:
inputs, outputs = self.ports self._set_editor_symbols(self.ports, self.parameters)
self._set_editor_symbols(inputs, outputs, self.parameters)
def _set_editor_symbols( def _set_editor_symbols(
self, self,
inputs: list[Port], ports: list[Port],
outputs: list[Port],
parameters: list[Parameter], parameters: list[Parameter],
) -> None: ) -> None:
self._apply_editor_symbols( self._apply_editor_symbols(
[port.name for port in inputs], [
[port.name for port in outputs], port.name
for port in ports
if port.orientation in {"input", "indifferent"}
],
[port.name for port in ports if port.orientation == "output"],
[parameter.name for parameter in parameters], [parameter.name for parameter in parameters],
) )

View File

@@ -458,8 +458,7 @@ class IconEditorDialog(QDialog):
self.ui.setupUi(self) self.ui.setupUi(self)
self.setWindowTitle(f"Icon Editor — {component.name}") self.setWindowTitle(f"Icon Editor — {component.name}")
self.icon = Icon.from_dict(component.icon.to_dict()) self.icon = Icon.from_dict(component.icon.to_dict())
self.inputs = deepcopy(component.inputs) self.ports = deepcopy(component.ports)
self.outputs = deepcopy(component.outputs)
self.tool_group = QButtonGroup(self) self.tool_group = QButtonGroup(self)
self.tool_group.setExclusive(True) self.tool_group.setExclusive(True)
self.tool_group.addButton(self.ui.pointerButton) self.tool_group.addButton(self.ui.pointerButton)
@@ -485,8 +484,10 @@ class IconEditorDialog(QDialog):
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100) self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
for element in self.icon.elements: for element in self.icon.elements:
self.scene.addItem(ShapeItem(element)) self.scene.addItem(ShapeItem(element))
self._add_ports(self.inputs, "input", 0.0) input_side = [port for port in self.ports if port.orientation != "output"]
self._add_ports(self.outputs, "output", self.icon.width) output_side = [port for port in self.ports if port.orientation == "output"]
self._add_ports(input_side, "input", 0.0)
self._add_ports(output_side, "output", self.icon.width)
self._initial_fit_pending = True self._initial_fit_pending = True
def showEvent(self, event) -> None: # noqa: N802 (Qt API name) def showEvent(self, event) -> None: # noqa: N802 (Qt API name)

View File

@@ -173,8 +173,16 @@ class ComponentGraphicsItem(QGraphicsObject):
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable | QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
) )
self.input_ports = self._create_ports(component.inputs, "target", 0.0) self.input_ports = self._create_ports(
self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH) [port for port in component.ports if port.orientation != "output"],
"target",
0.0,
)
self.output_ports = self._create_ports(
[port for port in component.ports if port.orientation == "output"],
"source",
self.WIDTH,
)
self.setTransformOriginPoint(self.hitbox.center()) self.setTransformOriginPoint(self.hitbox.center())
self.setRotation(component.rotation) self.setRotation(component.rotation)
self.name_label: NameLabelItem | None = None self.name_label: NameLabelItem | None = None
@@ -371,8 +379,10 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
super().__init__() super().__init__()
self.connection_id = connection.id self.connection_id = connection.id
self.name = connection.name self.name = connection.name
self.connection_type = connection.type
self.source_is_junction = connection.source.junction is not None self.source_is_junction = connection.source.junction is not None
self.target_is_junction = connection.target.junction is not None self.target_is_junction = connection.target.junction is not None
self.causality = connection.causality
self.controller = controller self.controller = controller
self.style = style or ConnectionStyle() self.style = style or ConnectionStyle()
self.start = QPointF() self.start = QPointF()
@@ -417,7 +427,11 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.setSelected(True) self.setSelected(True)
menu = QMenu() menu = QMenu()
add_node_action = menu.addAction("Add Node") add_node_action = menu.addAction("Add Node")
add_junction_action = menu.addAction("Add Junction") add_junction_action = (
menu.addAction("Add Junction")
if self.connection_type != "power"
else None
)
menu.addSeparator() menu.addSeparator()
options_action = menu.addAction("Connection Options…") options_action = menu.addAction("Connection Options…")
selected = menu.exec(event.screenPos()) selected = menu.exec(event.screenPos())
@@ -425,7 +439,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
scene = self.scene() scene = self.scene()
if isinstance(scene, GraphScene): if isinstance(scene, GraphScene):
scene.add_route_node("connection", self.connection_id, event.scenePos()) scene.add_route_node("connection", self.connection_id, event.scenePos())
elif selected is add_junction_action: elif add_junction_action is not None and selected is add_junction_action:
scene = self.scene() scene = self.scene()
if isinstance(scene, GraphScene): if isinstance(scene, GraphScene):
scene.add_connection_junction(self.connection_id, event.scenePos()) scene.add_connection_junction(self.connection_id, event.scenePos())
@@ -512,12 +526,36 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
if self.style.arrow_style == "open": if self.style.arrow_style == "open":
painter.drawLine(tip, right) painter.drawLine(tip, right)
def _draw_causality_mark(
self, painter: QPainter, point: QPointF, direction: QPointF, *, warning: bool
) -> None:
length = max(0.001, (direction.x() ** 2 + direction.y() ** 2) ** 0.5)
normal = QPointF(-direction.y() / length, direction.x() / length)
half_length = 6.0
color = QColor("#c25a00") if warning else self.pen().color()
painter.setPen(QPen(color, max(2.0, self.pen().widthF()), Qt.PenStyle.SolidLine))
painter.drawLine(point - normal * half_length, point + normal * half_length)
def paint(self, painter: QPainter, option, widget=None) -> None: def paint(self, painter: QPainter, option, widget=None) -> None:
super().paint(painter, option, widget) super().paint(painter, option, widget)
if self.style.arrow_at_target and not self.target_is_junction: if self.style.arrow_at_target and not self.target_is_junction:
self._draw_arrow(painter, self.end, self.end_direction) self._draw_arrow(painter, self.end, self.end_direction)
if self.style.arrow_at_source and not self.source_is_junction: if self.style.arrow_at_source and not self.source_is_junction:
self._draw_arrow(painter, self.start, -self.start_direction) self._draw_arrow(painter, self.start, -self.start_direction)
if self.causality in {"source", "warn_source"}:
self._draw_causality_mark(
painter,
self.start,
self.start_direction,
warning=self.causality == "warn_source",
)
elif self.causality in {"target", "warn_target"}:
self._draw_causality_mark(
painter,
self.end,
self.end_direction,
warning=self.causality == "warn_target",
)
class WaypointHandle(QGraphicsEllipseItem): class WaypointHandle(QGraphicsEllipseItem):
@@ -898,12 +936,12 @@ class GraphScene(QGraphicsScene):
owner = self.controller.active_component owner = self.controller.active_component
if owner is None or owner.implementation_kind != "graph": if owner is None or owner.implementation_kind != "graph":
return return
for port in owner.inputs: for port in (port for port in owner.ports if port.orientation != "output"):
item = InterfaceTerminalItem(port, "input", self.controller) item = InterfaceTerminalItem(port, "input", self.controller)
self.addItem(item) self.addItem(item)
item.setPos(port.x, port.y) item.setPos(port.x, port.y)
self.input_items[port.id] = item self.input_items[port.id] = item
for port in owner.outputs: for port in (port for port in owner.ports if port.orientation == "output"):
item = InterfaceTerminalItem(port, "output", self.controller) item = InterfaceTerminalItem(port, "output", self.controller)
self.addItem(item) self.addItem(item)
item.setPos(port.x, port.y) item.setPos(port.x, port.y)
@@ -1224,15 +1262,16 @@ class GraphScene(QGraphicsScene):
source_item: ComponentGraphicsItem, target_item: ComponentGraphicsItem source_item: ComponentGraphicsItem, target_item: ComponentGraphicsItem
) -> None: ) -> None:
source_ports = [ source_ports = [
*source_item.component.outputs, port
*( for port in source_item.component.ports
port if port.orientation in {"output", "indifferent"}
for port in source_item.component.inputs
if port.orientation == "indifferent"
),
] ]
for output in source_ports: for output in source_ports:
for input_port in target_item.component.inputs: for input_port in (
port
for port in target_item.component.ports
if port.orientation in {"input", "indifferent"}
):
if not PortTypeRegistry.compatible( if not PortTypeRegistry.compatible(
output.type, output.type,
input_port.type, input_port.type,
@@ -1267,7 +1306,11 @@ class GraphScene(QGraphicsScene):
choices: list[ConnectionChoice] = [] choices: list[ConnectionChoice] = []
interface = Endpoint(interface=terminal.port.id) interface = Endpoint(interface=terminal.port.id)
if terminal.direction == "input": if terminal.direction == "input":
for port in component.component.inputs: for port in (
port
for port in component.component.ports
if port.orientation in {"input", "indifferent"}
):
target = Endpoint(block=component.component_id, port=port.id) target = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible( if not PortTypeRegistry.compatible(
terminal.port.type, port.type, terminal.port.domain, port.domain terminal.port.type, port.type, terminal.port.domain, port.domain
@@ -1286,12 +1329,9 @@ class GraphScene(QGraphicsScene):
) )
else: else:
ports = [ ports = [
*component.component.outputs, port
*( for port in component.component.ports
port if port.orientation in {"output", "indifferent"}
for port in component.component.inputs
if port.orientation == "indifferent"
),
] ]
for port in ports: for port in ports:
source = Endpoint(block=component.component_id, port=port.id) source = Endpoint(block=component.component_id, port=port.id)
@@ -1319,7 +1359,11 @@ class GraphScene(QGraphicsScene):
) -> list[ConnectionChoice]: ) -> list[ConnectionChoice]:
choices: list[ConnectionChoice] = [] choices: list[ConnectionChoice] = []
source = junction.endpoint source = junction.endpoint
for port in component.component.inputs: for port in (
port
for port in component.component.ports
if port.orientation in {"input", "indifferent"}
):
target = Endpoint(block=component.component_id, port=port.id) target = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible(junction.junction.type, port.type): if not PortTypeRegistry.compatible(junction.junction.type, port.type):
continue continue
@@ -1600,6 +1644,8 @@ class GraphScene(QGraphicsScene):
graphics = self.connection_items.get(connection_id) graphics = self.connection_items.get(connection_id)
if connection is None or graphics is None: if connection is None or graphics is None:
return return
if connection.type == "power":
return
snapped = _snapped(position) snapped = _snapped(position)
anchors = [graphics.start, *points, graphics.end] anchors = [graphics.start, *points, graphics.end]
@@ -1850,8 +1896,7 @@ class GraphWorkspaceView(QGraphicsView):
return return
blocks: set[str] = set() blocks: set[str] = set()
connections: set[str] = set() connections: set[str] = set()
inputs: set[str] = set() ports: set[str] = set()
outputs: set[str] = set()
annotations: set[str] = set() annotations: set[str] = set()
for item in self.scene().selectedItems(): for item in self.scene().selectedItems():
if isinstance(item, ComponentGraphicsItem): if isinstance(item, ComponentGraphicsItem):
@@ -1859,10 +1904,10 @@ class GraphWorkspaceView(QGraphicsView):
elif isinstance(item, ConnectionGraphicsItem): elif isinstance(item, ConnectionGraphicsItem):
connections.add(item.connection_id) connections.add(item.connection_id)
elif isinstance(item, InterfaceTerminalItem): elif isinstance(item, InterfaceTerminalItem):
(inputs if item.direction == "input" else outputs).add(item.port.id) ports.add(item.port.id)
elif isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem)): elif isinstance(item, (AnnotationGraphicsItem, LineAnnotationGraphicsItem)):
annotations.add(item.annotation_id) annotations.add(item.annotation_id)
self.controller.delete_selection(blocks, connections, inputs, outputs) self.controller.delete_selection(blocks, connections, ports)
self.controller.delete_annotations(annotations) self.controller.delete_annotations(annotations)
def has_selected_components(self) -> bool: def has_selected_components(self) -> bool:

View File

@@ -540,8 +540,7 @@ class MainWindow(QMainWindow):
component.source.get("declarations", ""), component.source.get("declarations", ""),
component.source.get("initialEquations", ""), component.source.get("initialEquations", ""),
component.source.get("equations", ""), component.source.get("equations", ""),
component.inputs, component.ports,
component.outputs,
component.parameters, component.parameters,
) )
@@ -569,12 +568,11 @@ class MainWindow(QMainWindow):
@Slot() @Slot()
def apply_text_definition(self) -> bool: def apply_text_definition(self) -> bool:
try: try:
inputs, outputs = self.ui.textDefinitionEditor.ports ports = self.ui.textDefinitionEditor.ports
self._applying_text_definition = True self._applying_text_definition = True
try: try:
self.document_controller.replace_active_text_definition( self.document_controller.replace_active_text_definition(
inputs, ports,
outputs,
self.ui.textDefinitionEditor.declarations, self.ui.textDefinitionEditor.declarations,
self.ui.textDefinitionEditor.initial_equations, self.ui.textDefinitionEditor.initial_equations,
self.ui.textDefinitionEditor.equations, self.ui.textDefinitionEditor.equations,
@@ -643,12 +641,23 @@ class MainWindow(QMainWindow):
) )
if not filename: if not filename:
return return
self.open_document_path(Path(filename), check_unsaved=False)
def open_document_path(self, path: Path, *, check_unsaved: bool = True) -> bool:
"""Open a document path selected by the UI or supplied at startup."""
if check_unsaved and (
not self._resolve_source_edits() or not self._maybe_save()
):
return False
try: try:
self.document_controller.load(Path(filename)) self.document_controller.load(path)
self.log.info("Opened document %s", filename) self.log.info("Opened document %s", path)
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
self.log.error("Could not open document %s: %s", filename, error) self.log.error("Could not open document %s: %s", path, error)
QMessageBox.critical(self, "Could not open graph", str(error)) QMessageBox.critical(self, "Could not open graph", str(error))
return False
return True
@Slot() @Slot()
def save_document(self) -> bool: def save_document(self) -> bool:
@@ -818,9 +827,8 @@ class MainWindow(QMainWindow):
elif selected is ports_action: elif selected is ports_action:
dialog = PortOptionsDialog(component, self) dialog = PortOptionsDialog(component, self)
if dialog.exec() == dialog.DialogCode.Accepted: if dialog.exec() == dialog.DialogCode.Accepted:
old_inputs, old_outputs = deepcopy(component.inputs), deepcopy(component.outputs) old_ports = deepcopy(component.ports)
component.inputs = dialog.inputs component.ports = dialog.ports
component.outputs = dialog.outputs
library = next( library = next(
( (
library library
@@ -834,7 +842,7 @@ class MainWindow(QMainWindow):
library.document.validate() library.document.validate()
DocumentSerializer.save(library.document, Path(library.source_path)) DocumentSerializer.save(library.document, Path(library.source_path))
except (OSError, ValueError) as error: except (OSError, ValueError) as error:
component.inputs, component.outputs = old_inputs, old_outputs component.ports = old_ports
self.log.error("Could not change library ports: %s", error) self.log.error("Could not change library ports: %s", error)
QMessageBox.warning(self, "Cannot change library ports", str(error)) QMessageBox.warning(self, "Cannot change library ports", str(error))
self.library_tree_model.rebuild() self.library_tree_model.rebuild()
@@ -876,7 +884,7 @@ class MainWindow(QMainWindow):
return return
try: try:
self.document_controller.edit_component_ports( self.document_controller.edit_component_ports(
component_id, dialog.inputs, dialog.outputs component_id, dialog.ports
) )
except ValueError as error: except ValueError as error:
self.log.error("Could not change component ports: %s", error) self.log.error("Could not change component ports: %s", error)
@@ -912,8 +920,7 @@ class MainWindow(QMainWindow):
component_id, component_id,
dialog.ui.nameEdit.text().strip(), dialog.ui.nameEdit.text().strip(),
dialog.edited_icon, dialog.edited_icon,
dialog.edited_inputs, dialog.edited_ports,
dialog.edited_outputs,
dialog.ui.showSubtreeCheckBox.isChecked(), dialog.ui.showSubtreeCheckBox.isChecked(),
dialog.ui.showNameCheckBox.isChecked(), dialog.ui.showNameCheckBox.isChecked(),
) )
@@ -926,8 +933,7 @@ class MainWindow(QMainWindow):
owner = self.document_controller.active_component owner = self.document_controller.active_component
if owner is None: if owner is None:
return return
ports = owner.inputs if direction == "input" else owner.outputs port = next((item for item in owner.ports if item.id == port_id), None)
port = next((item for item in ports if item.id == port_id), None)
if port is None: if port is None:
return return
dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self) dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self)

View File

@@ -14,8 +14,7 @@
}, },
"rotation": 0.0, "rotation": 0.0,
"interface": { "interface": {
"inputs": [], "ports": []
"outputs": []
}, },
"parameters": [], "parameters": [],
"icon": { "icon": {
@@ -69,7 +68,7 @@
}, },
"rotation": 0.0, "rotation": 0.0,
"interface": { "interface": {
"inputs": [ "ports": [
{ {
"id": "port-2e1d884f", "id": "port-2e1d884f",
"name": "p", "name": "p",
@@ -96,9 +95,7 @@
"orientation": "input", "orientation": "input",
"domain": "power", "domain": "power",
"causality": "preferred effort out" "causality": "preferred effort out"
} },
],
"outputs": [
{ {
"id": "port-ca7716d5", "id": "port-ca7716d5",
"name": "state", "name": "state",
@@ -193,7 +190,7 @@
}, },
"rotation": 0.0, "rotation": 0.0,
"interface": { "interface": {
"inputs": [ "ports": [
{ {
"id": "port-3e53bf80", "id": "port-3e53bf80",
"name": "p", "name": "p",
@@ -221,8 +218,7 @@
"domain": "power", "domain": "power",
"causality": "indifferent" "causality": "indifferent"
} }
], ]
"outputs": []
}, },
"parameters": [], "parameters": [],
"icon": { "icon": {
@@ -275,7 +271,7 @@
}, },
"rotation": 0.0, "rotation": 0.0,
"interface": { "interface": {
"inputs": [ "ports": [
{ {
"id": "port-2e1d884f", "id": "port-2e1d884f",
"name": "p", "name": "p",
@@ -303,8 +299,7 @@
"domain": "power", "domain": "power",
"causality": "indifferent" "causality": "indifferent"
} }
], ]
"outputs": []
}, },
"parameters": [ "parameters": [
{ {
@@ -371,8 +366,7 @@
}, },
"rotation": 0.0, "rotation": 0.0,
"interface": { "interface": {
"inputs": [], "ports": [
"outputs": [
{ {
"id": "port-2e1d884f", "id": "port-2e1d884f",
"name": "p", "name": "p",
@@ -473,7 +467,9 @@
"name": "", "name": "",
"properties": { "properties": {
"waypoints": [] "waypoints": []
} },
"type": "power",
"causality": "none"
}, },
{ {
"id": "a73a99a1-54e2-461d-a9a7-2d6d11d500f4", "id": "a73a99a1-54e2-461d-a9a7-2d6d11d500f4",
@@ -488,7 +484,9 @@
"name": "", "name": "",
"properties": { "properties": {
"waypoints": [] "waypoints": []
} },
"type": "power",
"causality": "none"
}, },
{ {
"id": "dd5fb1e2-b125-4b5f-a792-dac89dd89ad3", "id": "dd5fb1e2-b125-4b5f-a792-dac89dd89ad3",
@@ -503,7 +501,9 @@
"name": "", "name": "",
"properties": { "properties": {
"waypoints": [] "waypoints": []
} },
"type": "power",
"causality": "none"
} }
], ],
"annotations": [], "annotations": [],