Compare commits

...

11 Commits

Author SHA1 Message Date
8a6203d01e Fixed some bugs 2026-07-22 19:16:56 +02:00
2d49f65ec4 Fixed some bugs 2026-07-22 18:13:13 +02:00
c918a6a428 causality inference done 2026-07-22 18:04:07 +02:00
32154386e8 Start with causality inference 2026-07-22 13:20:49 +02:00
09248421d0 Bond graph ports and drawing added 2026-07-22 12:24:58 +02:00
0c578af85d port and param editor in and outside of text editor the same 2026-07-22 11:45:43 +02:00
edefb23edc More types and units 2026-07-22 11:38:07 +02:00
48ac9e2f59 proper modelica text model editing 2026-07-21 17:16:06 +02:00
2f6487e510 Added direct graph navigation without activating Matplotlib toolbar modes:
Mouse wheel zooms in and out around the cursor.
Left-click dragging pans the graph.
Matplotlib toolbar modes still work and take precedence when activated.
The existing Home button can reset the view.
2026-07-21 15:29:58 +02:00
eb8f77ce64 better signal graphing 2026-07-21 15:24:24 +02:00
35d933ccbb Added signal graphing 2026-07-21 15:14:12 +02:00
49 changed files with 4622 additions and 1199 deletions

View File

@@ -115,6 +115,14 @@
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": []
},
{
"label": "Qt: Compile Simulation Window UI to Python",
"type": "shell",
"command": "pyside6-uic",
"args": ["--from-imports", "${workspaceFolder}/ui/simulation_window.ui", "-o", "${workspaceFolder}/src/bedit/gui/generated/ui_simulation_window.py"],
"options": {"cwd": "${workspaceFolder}"},
"problemMatcher": []
},
{
"label": "Qt: Build Designer Files",
"dependsOrder": "sequence",
@@ -125,7 +133,8 @@
"Qt: Compile Component Options UI to Python",
"Qt: Compile Port Options UI to Python",
"Qt: Compile Shape Options UI to Python",
"Qt: Compile Icon Editor UI to Python"
"Qt: Compile Icon Editor UI to Python",
"Qt: Compile Simulation Window UI to Python"
],
"problemMatcher": [],
"group": {

View File

@@ -56,16 +56,33 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
anchors may change without changing IDs.
- Parameters belong to `Component` rather than a particular implementation kind,
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
indexes inputs and outputs separately for connection semantics.
- Components store one ordered `ports` list. Each port's `orientation` field is
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
connected. `signal` is currently the only type.
connected. Signal ports connect by type; power ports additionally require the
same domain. Editable power domains and causalities live in
`core/power_domains.py`. The `single effort in` and `single flow in`
causalities are available only on power ports that allow multiple connections.
- Power ports may use `indifferent` orientation and can act as either connection
endpoint. For two indifferent ports, click order determines source/arrow
direction; otherwise output/input semantics determine direction.
- Port connector type and signal value type are separate. Signal ports and
parameters carry editable value type, quantity, unit, row/column dimensions,
and description metadata. Editable quantity/unit suggestions live in
`core/physical_types.py`; unlisted values remain valid.
- Port removal or reorientation must be rejected when it would invalidate an
existing connection.
- 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
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
visible in Connect mode. Connecting two blocks opens the compatible port-pair
chooser; explicit port clicks determine its default selection. Connections
@@ -74,7 +91,8 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
intersection of the owning hitbox and its center-to-adjacent-route-point ray.
- Connection appearance is configured per port type in
`gui/graphics/connection_styles.py`, including color, width, pen style, and
source/target arrowheads. Do not scatter those constants through painters.
source/target arrowheads. Arrow styles are `open`, `half`, or `filled`. Do not
scatter those constants through painters.
- Graph annotations are `box`, `line`, or `text` objects in `Graph.annotations`.
They use integer layers below or above graph layer 0. Annotation lines reuse
connection polyline and absolute `properties.waypoints` semantics. Graph
@@ -104,6 +122,14 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
named BEdit `$name$` macro completions editable there; arbitrary `$name$`
expressions are highlighted as BEvalues. Highlight colors and bold/italic
styles are persisted under `syntax/<category>/` in application settings.
- Text component sources may contain private Modelica declarations in
`source.declarations`. The text-definition editor exposes declarations above
`source.initialEquations` and `source.equations`, with the same highlighting
and completion in all three fields. The composer emits each in its matching
Modelica section.
- The text-definition view embeds the same `PortEditor` and `ParameterEditor`
implementations used by the standalone options dialogs. Do not reintroduce
separate port/parameter tables in the text editor.
- Application-wide messages use `core.application_log.get_logger()`. The main
window installs the Qt log-panel handler; core code must only use standard
Python logging and must not import the GUI handler.
@@ -116,6 +142,8 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
per component instance from graph connections and exposed while compiling as
`$portname_N$`; array connection endpoints receive stable one-based indices in
graph connection order.
- Simulation → Export Model composes through `Simulation.compose_source()` without
building the model, then writes the generated source as a `.mo` file.
- OpenModelica integration belongs in `core/simulation/openmodelica.py`. Its
persistent worker and OMC session start lazily on the first queued request.
Never perform OMPython work directly on the Qt GUI thread. Result and error
@@ -131,6 +159,8 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
progress, log, and future result views. Extend graph presentation through its
Designer-owned `resultsLayout` and the
`clear_result_views()`/`load_result_views()` hooks.
- Simulation-window geometry, dock/toolbar state, and central-results visibility
persist under `simulationWindow/` through `application_settings()`.
- Standalone simulation results are modeled in `core/simulation/results.py`.
Its versioned schema retains model status, messages, metadata, and plottable
traces so the simulation window can open results without an active document.
@@ -139,10 +169,35 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant
- After a successful OpenModelica run, `<model>_res.csv` is parsed on the worker
before temporary-directory cleanup. `SimulationResults.data` stores every CSV
column as a numeric array, including `time`, for later plotting and persistence.
- The simulation window's dockable Signals tree derives hierarchy from dot-separated
result-column names and bracketed array indices (`a[1]` becomes `a → 1`). Leaf
items retain the exact full column name in `UserRole`; plotting code should
consume `SimulationWindow.selected_signal_names()`.
- Simulation graph tabs persist as `SimulationResults.graphs`; every graph has a
stable ID, editable title, and its own `traces` list. Runtime graph widgets belong
in `GraphWorkspacePage.plot_layout`, not in the serialized core model. The
Signals tree checkboxes edit the active graph's traces, and each page embeds a
Matplotlib QtAgg canvas. Each graph persists its own `x_axis` signal (default
`time`), selectable from the Signals tree context menu.
- Embedded Matplotlib canvases provide cursor-centered wheel zoom and direct
left-button drag panning without activating navigation-toolbar modes. Their
custom navigation toolbar restores an explicit data-derived home view.
- Rerunning the same composed model retains graph tabs, ordering, titles, X axes,
and surviving trace settings while replacing numeric data. Missing signals are
pruned from traces/X-axis selection and newly returned columns appear in the tree.
- The optional OpenModelica executable is persisted as
`simulation/openModelicaPath`. The GUI passes it into `Simulation`; core must
not read `QSettings`. An empty path uses OMPython/PATH discovery, while an
explicit `.../bin/omc` path is converted to the OpenModelica home directory.
- Builds enable OpenModelica's `--unitChecking`. Completed simulation results
read per-column units from OMC's generated `<model>_init.xml`; BEdit must not
infer derivative units itself. This metadata is serialized with `.ber`/JSON
results and displayed by the simulation signal tree and plots.
- Result loading restores variables marked `alias` or `negatedAlias` in OMC's
initialization XML. Values come from OMC's result representative or a fixed
parameter/constant start value; BEdit does not infer aliases from graph edges.
- Compile requests call OMC `checkModel` before `buildModel` and log OMC's check
summary verbatim through the application-wide logger.
## Qt Designer and generated files
@@ -277,6 +332,7 @@ PYTHONPATH=src python3 -m bedit
```
The installed GUI entry point is `bedit.gui.app:main`.
The first non-flag launch argument is opened as the initial document.
## Completion checklist

View File

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

View File

@@ -9,6 +9,7 @@ description = "A starter Qt desktop application"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"matplotlib>=3.8,<4",
"msgpack>=1.0,<2",
"PySide6>=6.7,<7",
"OMPython>4.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1,5 +1,7 @@
<RCC>
<qresource prefix="icons">
<file>icons/list-remove.png</file>
<file>icons/list-add.png</file>
<file>icons/view-form-table.png</file>
<file>icons/office-chart-line.png</file>
<file>icons/run-build.png</file>

View File

@@ -862,6 +862,75 @@ N\xa7\xd3>==\xfd\xa1\xc3\xe1\x98\x92$\xe9I\x00\
\xbf\xc9\xd3\x01\x9f\xa3\x0f\x17Z/\xe3\x7f\xe1/\x17\x85\
\xd6\x06q(\x0e\x10\x00\x00\x00\x00IEND\xaeB\
`\x82\
\x00\x00\x04)\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\
\x00\x00\x00\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\
\xa7\x93\x00\x00\x00\x09pHYs\x00\x007\x5c\x00\x00\
7\x5c\x01\xcb\xc7\xa4\xb9\x00\x00\x03\x9bIDATx\
\xda\xedT=o\x1bG\x10}\xbb\xb7w$\xf5A\x9e\
lJEB\x18\xb1\x0a\x1bv\x13\xdb\x85\xaa\x18P$\
\xa4\x89\x81\x94\xd6_\x08`\xb8r\x93 \x85\x02\x01\xa9\
\x9c\x1f\xe0>\x85\x81\x94F\x00\xa7\x11\xd88\x85\x1bK\
6@)\x94-\xc8\x96\x10\x18\x81\x22\xf3C\x22i\xf2\
\xf6v\xf3B.s\xd1\xb1H\x80\xb83\x07x\x98\xbd\
\xdd\x99y3\xb3\xb7\x83\xb1\x8ce,\xef\xbd\x88\x1f\x97\
\x97\xe1\x05\x01\x820D~~\x1e\xdb\xf7\xee\xa1st\
\x94\x188H\x07/\x05\xe5\xe0\x0f\xb5C\xf0\xb7N\xd6\
\xb9\xb3g\x11\xde\xba\x85\xf8\xd5+\xc4\xb5\x1a`\x0c\xc4\
\x83B\x01\x82\xe4\xe1\xcd\x9b\xf2\x8f'Ol.\x9f\xb7\
\x96\x07\xd1\xc9\x09\xbeX_\xc7\xbb\x90\xd7\xd7\xafCN\
M\x01RB7\x9bbraA4\xee\xdf7q\xa3\
\x01\xf1\xc3\xf9\xf3\xf0\xc3P\xac\x90\xfc\xd1\xda\x9a|v\
\xf7\xee4\xc9\xbdw\xda\x01\x87\xec\xf4t<\xb7\xba\xda\
\xcc\xdc\xb9c\xebW\xae\x88\xb8^\xb7\xa2|\xfb\xb6\xf8\
\xe0\xeaU\xdbl4>?\xda\xd8\xf8\xf6\xa0\x5c\xfe\xa8\
yp \xff=\x01G\x9eN\x22!\x1fM\xa0T2\
S\x8b\x8b{\xb9\x85\x85U\xe4r?G\x9b\x9bB\xd5\
\xaaU[\x7f\xfe|)\xbcp\xe1Avv\xd6\x93a\
h\xdb\x07\x07B\xfc\xe7\x04\x122\xe3`\x07p~\x09\
\x04c\xa3X\x9c\xd5;;?\x89\xdd\xdd\xcf\xa41e\
e\x94B\xb3Z\xfd\xca\xfa\xbeWZZ\x8a>^Y\
Q\xf3\xec\x88\xa7\x94#'\xac\x85L\x05\x1c\x09\x9e\xe8\
S~\x88cxR\x22\xc8\xe7\xa1\xe7\xe6`\x84\x88\xf4\
\xe3\xc7\xbe\xa8V\xbf6\x17/\x96\xd5\xeb\x87\x0f\x0bZ\
\xa9K\xad(\xc2\x87\xd7\xaey\xc1\x8b\x17b\xe6\xf8\x18\
\xca\xf3\xe0Y\x9bT<B\x98\x00B\x0c\xaa\xa6\xbd!\
bc\xfa\xdad2\x00\x89\xdbL`\xfb\xe9S\xfc\xba\
\xb5\x85Oo\xdc\xf0J\x95\x0a\xec\xfe\xfe\xa5\xdc\xcb\x97\
\x05\x15[\xabzQ\xe4\xd7\xf6\xf7qD\x833$\xb7\
LF1\xa8JZ\x9dJ\xc2\x81$\x18VJX\x12\
\x81\xa4rr\x12&\x9bE\xa7\xd3\xc1\xef\x9b\x9b\xf8m\
{\x1b\xf5\xb7o\xd1e\xcc\xe3j\x15zo\x0f\xf4\xf1\
\xd9\x0d\xa5\x0c\x00\x87~\xe6\xacf\x00\x0a\x83\x9e\xd6\xff\
l=\xc9$\xbb$\x95\x02\x82\x00\xa0\x8e\xe9\xd7k\xb7\
\xd1\x22A\x83o\xbd~x\x88\xb6\xf3\xcb\xf2\xacEm\
\xc8A\xe2~\xd2\xc6\x15\xa7\xb9\x88b\x00o8\x1c\xba\
t\x0a\xba]L\xb1\x82\xac\xef#\xabT\x9f\xcc#\x04\
\x1d\xe5\x10\x0c \xb4\x86\xe9\xf5`834\xdft\xaf\
^G\xf7\xafJ\x1di\x86v\x9a~]\xdaj\x12c\
b\x02\xbe\xbb\x22IN^\x99V\x87B4&\xac\xad\
\x00(\xedll\xe8\xe2\xb9s\xbeGbEC\x9fW\
\xe1\xc71|!\x86W\xd2\xdfW\x0c\xe0q_\x11\xc3\
\xffD\xce\xcc@\x16\x8b\x10\x1e\xbf\x5c\x85Y\x22\x00P\
\xa0\xd6\xd4>\x8b9\xb3\xbb\xab\xa3A\xcf*5r\x8b\
5\xf4\xe5\x93\x0e\xb0~\xc2\x03\x1eZ#%\xfd\x93\xb6\
\xb3\xda\xd3O\x8f\x04\xbe\xd3\xa7\xde<\xed2\xc0\x08\xb2\
\x0e\x19\x06\xa5\xbf\xe0\xbaG\xbfe\x00\x8f\xd4174\
\x17\x0c\xb0\x04\xe0;\x03\x5cf\x8b\xa4M?\xad\xd4\xcf\
G\x0dV\x9f\x9e\x0f\xce~ v\xf8o\x11zpn\
\x14\xb0\xc5\xbdo\xda\xc0/\x92\xa6j\xd2\xcd\x8c<7\
\x9e\x01\x8b4\xca\xd3X%\x09$\xc3%\x91\xd1\xa4b\
\x87\xd40J\xdbk\xee5\xc9\x09=\xd8\xb2\xea\x8dK\
\xb6\x05\xc8\x90Z\xd0\x00\xceY\x11\xdf\xe3\xffI%\xf5\
mI\xdc\x224`\xfaE~\xe9\x0e\xa4\xd3=B\xa7\
F\xe9\xe8\xe8\x1dE\x90B&\xd1)\x0c\xc4`,c\
\x19\xcbX\x06\xf2'\xe3\xdf\x9d\x06\x06\xf6\x92\x0e\x00\x00\
\x00\x22zTXtSoftware\x00\x00\
x\xda+//\xd7\xcb\xcc\xcb.NN,H\xd5\xcb\
/J\x07\x006\xd8\x06X\x10S\xca\x5c\x00\x00\x00\x00\
IEND\xaeB`\x82\
\x00\x00\x02V\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -2383,6 +2452,101 @@ u1\xb8\xdd0`x\xe8F\xf9\x1e\xe19\xea\x8a\x03\
|f\x1e\x89\xd1\x9ef\xbdhx\x0fE4mL\xaf\
b/B\x06\x12o\xbf\x00\xa3\x17WYZq\xd9W\
\x00\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x05\xcf\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\
\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\
\x00\x00\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\
\x01}\xd5\x82\xcc\x00\x00\x00\x19tEXtSof\
tware\x00www.inksca\
pe.org\x9b\xee<\x1a\x00\x00\x05LID\
ATx\xda\xc5\x97\xdboTE\x1c\xc7\xbf3s\xce\
\x9e\xeeni\x0b\xbd@\x01C1\x05B\x10\x0d\xf4\x22\
\x18\x95D0i\xc4\x17|21\xc6\x17\xc3\x93\x89\xd1\
\xc8\x8b\xfaP#oj\x8c\x89O\xfc\x03\xc6K\x0c\xf1\
\x01!B\xa8\xc1j\x88D\x90@)\x14\xac\xb5\x86K\
K\xaf\xdb\xdd={\xce\xcc\xef\xe7\xec\x9cficb\
\xd2\x98\x13f\xf3\xdd93\xbf\x93\x9d\xcf\xfengW\
03\x1e\xe6\xf0\xb0\xc2\xf1\xca\xe9\x17wok\xda~\
lh\xe6\xe2\xd6\xbf\x0a#\xab\x94\x92h\xcb\xad\x9d{\
z\xdd\xfe\x9b\xf9 \xf7\xce\xe1\xad\xef\x0d`\x05Cb\
\x05\xa3\xe7\xcb\xce\xc7\x0emz\xf9\xfc\xab\x9d\x87\xbb\xf6\
\xb4\xec[\x15k\x82\x8e\x09\x9d\xf9\xed\x8d\x07\xda\x0fu\
=\xd5\xd2w\xe6\x83K\x87{S\xf3@N\xe4>\x9d\
)M{>\xd5\xa1\xab\xad\x17\x1b\x1a\xda\xdd~V\xe6\
\xb1)\xb7\x05\xe7\xee\x9e\x94\xf3\xa5\xe2'\x00\x9eI\x05\
\xe0^a\xa2\xe7\xdc\xdf\x03\xe8\x5c\xb3\x05\xf3\xb9k\x10\
A\xc5\xed\x87\x98\xc4\x99\xdb\xdf\xe1\xd4\xd8\xb7\x18-\x0e\
\xefL\xcd\x03aTi\x1c\x1c\xff\x11\xdd\x1bw\xa3I\
\x99\xe5F3\x87+\xf7/\xc2W\xb215\x00\x133\
\x08\x11E(\xc7\xcb\x01<\xa3\x9d]2#5\x00\
\xd2\x04!$\x8c!\x94u\xb4\xccVG1\x8c6\x10\
@\x9a\x00\x0c\x08\x86!B\x18G\x80X\xea\x01\x03c\
\xed\x02\x94\x1e\x80Y\x04\xd0d`,\x80\x00j\x10\x01\
i\x18\xc3\x10\xd6\x9ej\x0e\x08\xc5 bD:^\xe6\
\x01M\xfa\xffy\xe0\xe8\xe0\xd1\x17\xaeN\x5c\xed\x1f\x18\
\x1d\xd869w\xafA\x92\x84d\xc0\xbeC\xb1\x80\x94\
\x02JHw81!2\x0e\xa0\xc6\xa0\x8dv9b\
\xd0ul\x03{\xbe\x84\xaf\x14<_\xc1\xf7\xecu\
F\xa15\xd76\xff\xc4\xda\x9e\xeb\x1d\xab\xb6\xf7\xbf\xb4\
\xf9\xf5\x13\xb5N\xd8\xf7E_\xefTi\xea\xf8\xc6\xfa\
\x8d=mA[\x03\x99$\xdel\xd8\xcd\xb48\x1bw\
M\xd0\xdaX\x00\x8d\xd8\x98%\x8a@D\xee^c\xc8\
\xddk(\xb9\xdfp\xb2W\xef564\xaa\xd6\x9e\xf9\
h\xea\xf8G\xc3o\xb8\x8e\xe9\x1eF\x1d\x9fw\xfc\xb0\
\xabm\xd7\x81\xbd\xeb\xf7\x02\xc2`.\x9aAu\x9f\x85\
\x15\x5c\xdc\x935\x0c\x84\x00r\xb9,\xee\x9a?!\x93\
$p{\x1b\xbcGQ\xa9\x84P\xd2\x83'\xfd\x9a\x94\
\xf2\x90\xb1{\x81\xca\xc2g\xcf\xda\x0d\xc6\xc2!\xdc\x89\
n\x9e\xf9\xec\xc9\xef\x0f\xb8\x10\x8c\xcf\x8dw#\x06z\
\xda\xbb0T\xb8\x00-4 Q\x93\x84\x80\x14\x02J\
J7\xcb(Y\x0b'\xc7\x87\xb1\xe8\x06X\x00L\x9c\
x\x81\xaa\x22\x042\x8b\xbcX\x8d\x91\x89Q\x9c\x1a:\
\x8d\xf7\xf7\xbd\x85\xdb4\x8cyLv\xd5r\x80\x88\x9a\
\xc6\xa6\xc602u\x1dEQN\x0eVp\xb3\x10H\
\x0e\x96\x02\xcc\xb2\x06\xc2\xe2\x01Dm0\x83\x00x\xc2\
\xc3\xba\xecz\xcc\x95\x8b\xb81}\x0bgG\xbfF\x1c\
\x11\x00\xd8\xf50\x8a\xc1md|\xd9T\x03\x00[\x91\
k\xb5\xa8\xa88\x01 \x07\x91$\x1fK+\x07\x90\x1c\
\x0e\x86\xaa\x02A8\xefdT\x1d\x9a\x83V\x17\x8e0\
\x0a1\xb10\x81o\xfe\xf8\x0a\x85J\x11\xa5(\xb6\xa0\
\x12\x90\xe42V\xb3v\xf9@\x9e\xc0R\x80Y\x10\x9a\
f\x0a\xb3\xb8^\xbc\x09?\xf0\x9c\x82L\x80l\x10\xc0\
>\xe7\x91\xf7\xb2\x8eI2\x01,]G\x0cD\x00\x22\
\x8d\xf9h\x1a\xc3\x93\xbf!\xd4\x15h\x22D\xc6\x00\xd2\
\xc0S\xca\xca@\x19W.N\x19\xcf\xc3\x02\xbb\xf0\xcc\
.-\xc3A\x10\x0e\x0e\x0e\xff\x82\x1d\xebw .\xc5\
@9\x09A$\x04H\x85(\x8b\x18\x9e'\xdc\x87\xd6\
5H\xdc\xc1-\x04\x9erkea\x1e\xf1;Q\xd1\
\x06\xbc\x987\xe43L\x96\xa09)[cU\xefe\
1e\x93\x97du\x8f\x7f~\x00\xa0q\x04\xc0\xfe\x85\
\xf2B\xdd\xf9k\xe7\x81x1\x04\x9cP\x0b+)\x92\
^ \x94\xc0\xb3{z\xe1\xafV\xc8T3\xbc\x0a!\
%\xc2B\x8c\x9f.\xffj!%\xaa=@YyK\
\xe4\xfb\x0a\x85\x8cB&c\xd7\x19\xaf\xacY\x1f\xa9\xf5\
\x01\xfe\x90\xaf\x81\xd0\x03\xe0,\x08\x0b0@M\x1a`\
;SU\x94\x5ck\x13\xbbC}%\x1d@\xe0{\xae\
D\x99\x01\x02\xbb\xba\xa7\x7f\x89\xacx\xce\xdaN\xda\xb9\
\xfbD\xdf\xc8\xd0\xb2N\xc8\x1f\xf3\x15\x00\xcf\xfdg\xdb\
|M2\x0b\xd7\xf5\x9cG\xbc*\x80\xb2\x00\x9e\x87J\
r\x00$\x0b\x8c\xbd;#R\xf9MH&\xa9\xf3\x98\
b\xf0b|\x94L\xbc\x00h\x07@)\xfe\x1ep\x87\
3\x0bT\xe2\x18\xc2\x18\x18J\xbe\xb5X\xecF\xc9+\
E\x80\x04\x02(\xeb\x0a8\xd2P2\xb2J\x1aR1\
\xa2\xa4\x9d\xa4\x0e \x18\xa5(D\x14F\xae\xe6+\xb1\
F!\xac ,\xf9 k\xb3<)\x02\x88D\x0bq\
\x08*VP\xac\xc4\xae\x17\xb8\xf2,f\xc0\x92\xacd\
z\x00^F\x15\x1b\xb2A\xbeE\x05\xb8Q\x9aK\xda\
\xb4U\xf5\xb5)\xee@=1B.\x15R\xfbg\x94\
\xad\xf7\x7f\xdf\xd9\xde\x82\x83\xcdk\xb03n\x86\x9cR\
\xc0\xb4D\xb7\xea\xc5V\xde\x82<\xea!H^H\x0d\
\xdf\x1c\xbc\xb9ym\xa0W7*\xb4\xe6\xf3 c\
\xc0Dx\xa2\xf9q\xc8@Add$`\xdeN\x0d\
\xe0N\xff\xec\x05\xd9\x1c=?\xee\xf3\xe5\xfb\x22Z\xa0\
,\x83\xeb\x18*\xab\xc2 \x9b\xb9\x12\xa0n\xffD\x7f\
\xf1\x12V0\x1e\xfa\xdf\xf3\x7f\x00j\xf0\xda\xe8\xbc\xba\
\xd0\x0a\x00\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x04<\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -2649,6 +2813,10 @@ qt_resource_name = b"\
\x0f\xba\x1e'\
\x00d\
\x00r\x00a\x00w\x00-\x00p\x00a\x00t\x00h\x00.\x00p\x00n\x00g\
\x00\x0f\
\x020\x8b\xe7\
\x00l\
\x00i\x00s\x00t\x00-\x00r\x00e\x00m\x00o\x00v\x00e\x00.\x00p\x00n\x00g\
\x00\x0d\
\x0b\xe6\x1f\xa7\
\x00d\
@@ -2723,6 +2891,10 @@ qt_resource_name = b"\
\x00o\
\x00f\x00f\x00i\x00c\x00e\x00-\x00c\x00h\x00a\x00r\x00t\x00-\x00l\x00i\x00n\x00e\
\x00.\x00p\x00n\x00g\
\x00\x0c\
\x09\xc6\x19'\
\x00l\
\x00i\x00s\x00t\x00-\x00a\x00d\x00d\x00.\x00p\x00n\x00g\
\x00\x10\
\x03\xe6\xd3g\
\x00d\
@@ -2749,67 +2921,71 @@ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x1e\x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x02\x00\x00\x00 \x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x01\xfe\x00\x00\x00\x00\x00\x01\x00\x00N\xe8\
\x00\x00\x02\x22\x00\x00\x00\x00\x00\x01\x00\x00S\x15\
\x00\x00\x01\x9f{C\xf1'\
\x00\x00\x02>\x00\x00\x00\x00\x00\x01\x00\x00Z\x1a\
\x00\x00\x02b\x00\x00\x00\x00\x00\x01\x00\x00^G\
\x00\x00\x01\x9f\x7f\xa8\xa8)\
\x00\x00\x02\xec\x00\x00\x00\x00\x00\x01\x00\x00tV\
\x00\x00\x03\x10\x00\x00\x00\x00\x00\x01\x00\x00x\x83\
\x00\x00\x01\x9f{0\xc99\
\x00\x00\x03\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x8d\xaa\
\x00\x00\x01d\x00\x00\x00\x00\x00\x01\x00\x004B\
\x00\x00\x01\x9f\x84\xbd\x03\xb8\
\x00\x00\x03\xc8\x00\x00\x00\x00\x00\x01\x00\x00\x91\xd7\
\x00\x00\x01\x9f\x84\x8f\x00\xb9\
\x00\x00\x01\xc4\x00\x00\x00\x00\x00\x01\x00\x00D2\
\x00\x00\x01\xe8\x00\x00\x00\x00\x00\x01\x00\x00H_\
\x00\x00\x01\x9f\x7f&\x83\xcd\
\x00\x00\x01\xa4\x00\x00\x00\x00\x00\x01\x00\x00<J\
\x00\x00\x01\xc8\x00\x00\x00\x00\x00\x01\x00\x00@w\
\x00\x00\x01\x9f{C\xf1\x18\
\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x00\x91\x1b\
\x00\x00\x04\x16\x00\x00\x00\x00\x00\x01\x00\x00\x9b\x1b\
\x00\x00\x01\x9f\x7fY\xceg\
\x00\x00\x02\xa2\x00\x00\x00\x00\x00\x01\x00\x00f\xc8\
\x00\x00\x02\xc6\x00\x00\x00\x00\x00\x01\x00\x00j\xf5\
\x00\x00\x01\x9f\x7fV\xd5\xc0\
\x00\x00\x03Z\x00\x00\x00\x00\x00\x01\x00\x00\x84\xc5\
\x00\x00\x03~\x00\x00\x00\x00\x00\x01\x00\x00\x88\xf2\
\x00\x00\x01\x9f\x7f&\x83r\
\x00\x00\x02\xce\x00\x00\x00\x00\x00\x01\x00\x00m[\
\x00\x00\x02\xf2\x00\x00\x00\x00\x00\x01\x00\x00q\x88\
\x00\x00\x01\x9f\x7f&\x83\xb1\
\x00\x00\x01\x1c\x00\x00\x00\x00\x00\x01\x00\x00,\x08\
\x00\x00\x01\x9f\x7fY\xce\x82\
\x00\x00\x01\xe0\x00\x00\x00\x00\x00\x01\x00\x00Kh\
\x00\x00\x02\x04\x00\x00\x00\x00\x00\x01\x00\x00O\x95\
\x00\x00\x01\x9f{C\xf1.\
\x00\x00\x03\xfa\x00\x00\x00\x00\x00\x01\x00\x00\x95[\
\x00\x00\x04<\x00\x00\x00\x00\x00\x01\x00\x00\x9f[\
\x00\x00\x01\x9f\x84\x8f\xa6\x10\
\x00\x00\x01\x84\x00\x00\x00\x00\x00\x01\x00\x006\x9c\
\x00\x00\x01\xa8\x00\x00\x00\x00\x00\x01\x00\x00:\xc9\
\x00\x00\x01\x9f{{\xa5\xd5\
\x00\x00\x04&\x00\x00\x00\x00\x00\x01\x00\x00\x97\x0c\
\x00\x00\x04h\x00\x00\x00\x00\x00\x01\x00\x00\xa1\x0c\
\x00\x00\x01\x9f\x7f&\x83\x10\
\x00\x00\x03\xf8\x00\x00\x00\x00\x00\x01\x00\x00\x95H\
\x00\x00\x01\x9f\x84\xbd\x03\xde\
\x00\x00\x00\xfe\x00\x00\x00\x00\x00\x01\x00\x00(e\
\x00\x00\x01\x9f\x7f&\x83~\
\x00\x00\x036\x00\x00\x00\x00\x00\x01\x00\x00\x80\xe2\
\x00\x00\x03Z\x00\x00\x00\x00\x00\x01\x00\x00\x85\x0f\
\x00\x00\x01\x9f\x7f&\x83T\
\x00\x00\x00~\x00\x00\x00\x00\x00\x01\x00\x00\x14M\
\x00\x00\x01\x9f\x7fY\xce^\
\x00\x00\x00\xa2\x00\x00\x00\x00\x00\x01\x00\x00\x19Y\
\x00\x00\x01\x9f{0\xc9B\
\x00\x00\x01d\x00\x00\x00\x00\x00\x01\x00\x004B\
\x00\x00\x01\x88\x00\x00\x00\x00\x00\x01\x00\x008o\
\x00\x00\x01\x9f\x7f&\x83)\
\x00\x00\x03\x14\x00\x00\x00\x00\x00\x01\x00\x00{`\
\x00\x00\x038\x00\x00\x00\x00\x00\x01\x00\x00\x7f\x8d\
\x00\x00\x01\x9f{C\xf1N\
\x00\x00\x02p\x00\x00\x00\x00\x00\x01\x00\x00b\x99\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x00f\xc6\
\x00\x00\x01\x9f\x7f&\x82\xf2\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00!\xc5\
\x00\x00\x01\x9f{\x8d\xf34\
\x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9f{0\xc9+\
\x00\x00\x02\x1e\x00\x00\x00\x00\x00\x01\x00\x00RH\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x00Vu\
\x00\x00\x01\x9f{C\xf1=\
\x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\
\x00\x00\x01\x9f\x7f&\x83\x99\
\x00\x00\x04P\x00\x00\x00\x00\x00\x01\x00\x00\x99\xa2\
\x00\x00\x04\x92\x00\x00\x00\x00\x00\x01\x00\x00\xa3\xa2\
\x00\x00\x01\x9f{{\xa5\xe3\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x00\x0c\xc1\
\x00\x00\x01\x9f\x7f\xac\xf2\xc6\
\x00\x00\x01D\x00\x00\x00\x00\x00\x01\x00\x00/{\
\x00\x00\x01\x9f\x7fV\xd5\xe5\
\x00\x00\x03|\x00\x00\x00\x00\x00\x01\x00\x00\x88\xb7\
\x00\x00\x03\xa0\x00\x00\x00\x00\x00\x01\x00\x00\x8c\xe4\
\x00\x00\x01\x9f{0\xc9R\
"

View File

@@ -0,0 +1,321 @@
"""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:
# 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)
# Fixed causalities
for block in graph.get("blocks", []):
for port in block.get('interface', {}).get('ports', []):
if port.get('type', '') != 'power':
continue
if port.get('causality', 'indifferent') == 'fixed effort out':
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'fixed flow out':
propagate_from_port(block, port, 'flow out', graph, id_list)
# Preferred causalities
for block in graph.get("blocks", []):
for port in block.get('interface', {}).get('ports', []):
if port.get('type', '') != 'power':
continue
if is_port_fully_assigned(block, port, graph):
continue
if port.get('causality', 'indifferent') == 'preferred effort out':
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'preferred flow out':
propagate_from_port(block, port, 'flow out', graph, id_list)
# Soft choices may need several passes when a junction has multiple
# unresolved bonds. Stop as soon as a pass makes no further progress.
while True:
unresolved_before = sum(
con.get('type') == 'power' and con.get('causality', 'none') == 'none'
for con in graph.get('connections', [])
)
for block in graph.get("blocks", []):
for port in block.get('interface', {}).get('ports', []):
if port.get('type', '') != 'power':
continue
if is_port_fully_assigned(block, port, graph):
continue
if port.get('causality', 'indifferent') == 'likes effort out':
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'likes flow out':
propagate_from_port(block, port, 'flow out', graph, id_list)
elif port.get('causality', 'indifferent') == 'single effort in':
evaluate_junction_constraints(block, port, graph, id_list)
if not is_port_fully_assigned(block, port, graph):
propagate_from_port(block, port, 'flow out', graph, id_list)
elif port.get('causality', 'indifferent') == 'single flow in':
evaluate_junction_constraints(block, port, graph, id_list)
if not is_port_fully_assigned(block, port, graph):
propagate_from_port(block, port, 'effort out', graph, id_list)
elif port.get('causality', 'indifferent') == 'indifferent':
# Force an arbitrary assignment on the first unassigned bond connected to this port
propagate_from_port(block, port, 'effort out', graph, id_list)
unresolved_after = sum(
con.get('type') == 'power' and con.get('causality', 'none') == 'none'
for con in graph.get('connections', [])
)
if unresolved_after == 0 or unresolved_after >= unresolved_before:
break
# Last check
for con in graph.get('connections', []):
if con.get('type') == 'power' and con.get('causality', 'none') == 'none':
raise ValueError("System under-constrained: unresolved causal loops or disconnected elements remain")
for block in graph.get("blocks", []):
if isinstance(block, dict):
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 is_port_fully_assigned(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any]) -> bool:
block_id = block.get('id')
port_id = port.get('id')
for connection in graph.get('connections', []):
if connection.get('type') != 'power':
continue
source = connection.get('source', {})
target = connection.get('target', {})
if ((source.get('block') == block_id and source.get('port') == port_id)
or (target.get('block') == block_id and target.get('port') == port_id)):
if connection.get('causality', 'none') == 'none':
return False
return True
def propagate_from_port(block: dict[str, Any], port: dict[str, Any], desired_causality: str, graph: dict[str, Any], id_list: dict[str, Any]) -> None:
# find all connection on this specific port
attached_connections = []
for con in graph.get('connections', []):
if con.get('type') == 'power':
if con.get('source', {}).get('block') == block.get('id') and con.get('source', {}).get('port') == port.get('id'):
attached_connections.append(con)
elif con.get('target', {}).get('block') == block.get('id') and con.get('target', {}).get('port') == port.get('id'):
attached_connections.append(con)
if not attached_connections:
# unconnected port
return
# find first unassigned connection on this port
target_connection = None
for con in attached_connections:
if con.get('causality', None) == 'none':
target_connection = con
break
if target_connection is None:
# Nothing left to resolve
return
# Update
if target_connection.get('source').get('block') == block.get('id'):
target_connection['causality'] = 'target' if (desired_causality == 'effort out') else 'source'
else:
target_connection['causality'] = 'source' if (desired_causality == 'effort out') else 'target'
propagate_to_neighbor_from_connection(block, target_connection, graph, id_list)
def evaluate_junction_constraints(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any], id_list: dict[str, Any]) -> None:
# find all connection on this specific port
attached_connections = []
for con in graph.get('connections', []):
if con.get('type') == 'power':
if con.get('source', {}).get('block') == block.get('id') and con.get('source', {}).get('port') == port.get('id'):
attached_connections.append(con)
elif con.get('target', {}).get('block') == block.get('id') and con.get('target', {}).get('port') == port.get('id'):
attached_connections.append(con)
if not attached_connections:
# unconnected port
return
efforts_in = 0
efforts_out = 0
unnassigned_conns = []
# Count current states relative to the junction port
for con in attached_connections:
if con.get('causality', 'none') == 'none':
unnassigned_conns.append(con)
continue
is_source = (con.get('source').get('block') == block.get('id'))
if (is_source and con.get('causality', 'none')=='source') or (not is_source and con.get('causality', 'none')=='target'):
efforts_in += 1
else:
efforts_out += 1
# Single effort in
if port.get('causality', 'indifferent') == 'single effort in':
# ERROR CHECK: Multiple sources/blocks trying to claim effort control on a 0-junction
if efforts_in>1:
raise ValueError(f"Critical causality conflict: Multiple blocks are dictating effort to 0 junction: {block.get('name')}")
# 1 effort is coming in so all other ports must be effort out
if efforts_in == 1 and len(unnassigned_conns) > 0:
for con in unnassigned_conns:
desired = 'target' if (con.get('source').get('block')==block.get('id')) else 'source'
con['causality'] = desired
propagate_to_neighbor_from_connection(block, con, graph, id_list)
# No effort is coming in yet and one left so must be effort in
if efforts_in == 0 and len(unnassigned_conns) == 1:
con = unnassigned_conns[0]
desired = 'source' if (con.get('source').get('block')==block.get('id')) else 'target'
con['causality'] = desired
propagate_to_neighbor_from_connection(block, con, graph, id_list)
# Single flow in
elif port.get('causality', 'indifferent') == 'single flow in':
# ERROR CHECK: Multiple sources/blocks trying to claim flow control on a 1-junction
if efforts_out>1:
raise ValueError(f"Critical causality conflict: Multiple blocks are dictating flow to 1 junction: {block.get('name')}")
# 1 flow is coming in so all other ports must be flow out
if efforts_out == 1 and len(unnassigned_conns) > 0:
for con in unnassigned_conns:
desired = 'source' if (con.get('source').get('block')==block.get('id')) else 'target'
con['causality'] = desired
propagate_to_neighbor_from_connection(block, con, graph, id_list)
# No flow is coming in yet and one left so must be flow in
if efforts_out == 0 and len(unnassigned_conns) == 1:
con = unnassigned_conns[0]
desired = 'target' if (con.get('source').get('block')==block.get('id')) else 'source'
con['causality'] = desired
propagate_to_neighbor_from_connection(block, con, graph, id_list)
def propagate_to_neighbor_from_connection(block: dict[str, Any], connection: dict[str, Any], graph: dict[str, Any], id_list: dict[str, Any]) -> None:
# Get the neighbor block and port on the other side of the bond
neighbor_id = connection.get('target').get('block') if connection.get('source').get('block') == block.get('id') else connection.get('source').get('block')
neighbor_port_id = connection.get('target').get('port') if connection.get('source').get('block') == block.get('id') else connection.get('source').get('port')
neighbor_block = id_list.get(neighbor_id, None)
if neighbor_block is None:
raise ValueError(f"No other side of the bond found on connection {connection.get('id')}")
neighbor_ports = neighbor_block.get('interface').get('ports', [])
neighbor_port = None
for p in neighbor_ports:
if p.get('id') == neighbor_port_id:
neighbor_port = p
break
if neighbor_port is None:
raise ValueError(f"No other side of the bond found on connection {connection.get('id')}")
# Trigger evaluation on the neighbor
if neighbor_port.get('causality', 'indifferent') in ['single effort in', 'single flow in']:
evaluate_junction_constraints(neighbor_block, neighbor_port, graph, id_list)
else:
verify_component_compatibility(neighbor_block, neighbor_port, graph)
def verify_component_compatibility(block: dict[str, Any], port: dict[str, Any], graph: dict[str, Any]) -> None:
# Find the connection we are evaluating
conn = None
for c in graph.get('connections', []):
if (c.get('source').get('block') == block.get('id') and c.get('source').get('port') == port.get('id')) or (c.get('target').get('block') == block.get('id') and c.get('target').get('port') == port.get('id')):
if c.get('causality', 'none') != 'none':
conn = c
break
if conn is None:
return
# Figure out what causality was pushed onto this port from the outside world
state = conn.get('causality', 'none')
is_source = conn.get('source', {}).get('block') == block.get('id')
effort_out = (is_source and state == 'target') or (not is_source and state == 'source')
port_causality = port.get('causality', 'indifferent')
if port_causality == 'fixed effort out' and not effort_out:
raise ValueError(f"Critical source conflict: Fixed effort source '{block.get('name')}' forced into an input state.")
if port_causality == 'fixed flow out' and effort_out:
raise ValueError(f"Critical source conflict: Fixed flow source '{block.get('name')}' forced into an input state.")
if port_causality == 'preferred effort out' and not effort_out:
conn['causality'] = f'warn_{state}'
# log.warning(f"Storage block '{block.get('name')}' was forced into derivative causality (dependent state).")
if port_causality == 'preferred flow out' and effort_out:
conn['causality'] = f'warn_{state}'
# log.warning(f"Storage block '{block.get('name')}' was forced into derivative causality (dependent state).")

View File

@@ -6,6 +6,24 @@ from typing import Any
from uuid import uuid4
from bedit.core.port_types import PortTypeRegistry
from bedit.core.power_domains import (
MULTI_CONNECTION_POWER_CAUSALITIES,
POWER_CAUSALITIES,
POWER_DOMAINS,
)
def _dimensions(data: Any, subject: str) -> tuple[int, int]:
if not isinstance(data, dict):
raise ValueError(f"{subject} dimensions must be an object")
try:
rows = int(data.get("rows", 1))
columns = int(data.get("columns", 1))
except (TypeError, ValueError) as error:
raise ValueError(f"{subject} dimensions must be whole numbers") from error
if rows < 1 or columns < 1:
raise ValueError(f"{subject} dimensions must be at least 1")
return rows, columns
@dataclass
@@ -17,6 +35,15 @@ class Port:
properties: dict[str, Any] = field(default_factory=dict)
type: str = "signal"
allows_multiple_connections: bool = False
value_type: str = "real"
quantity: str = ""
unit: str = ""
rows: int = 1
columns: int = 1
description: str = ""
orientation: str = "input"
domain: str = "power"
causality: str = "indifferent"
def to_dict(self) -> dict[str, Any]:
return {
@@ -26,11 +53,20 @@ class Port:
"properties": self.properties,
"type": self.type,
"multipleConnections": self.allows_multiple_connections,
"valueType": self.value_type,
"quantity": self.quantity,
"unit": self.unit,
"dimensions": {"rows": self.rows, "columns": self.columns},
"description": self.description,
"orientation": self.orientation,
"domain": self.domain,
"causality": self.causality,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Port":
def from_dict(cls, data: dict[str, Any], default_orientation: str = "input") -> "Port":
position = data.get("position", {})
rows, columns = _dimensions(data.get("dimensions", {}), "Port")
return cls(
id=str(data["id"]),
name=str(data.get("name", data["id"])),
@@ -39,6 +75,15 @@ class Port:
properties=dict(data.get("properties", {})),
type=str(data.get("type", "signal")),
allows_multiple_connections=bool(data.get("multipleConnections", False)),
value_type=str(data.get("valueType", "real")),
quantity=str(data.get("quantity", "")),
unit=str(data.get("unit", "")),
rows=rows,
columns=columns,
description=str(data.get("description", "")),
orientation=str(data.get("orientation", default_orientation)),
domain=str(data.get("domain", "power")),
causality=str(data.get("causality", "indifferent")),
)
@@ -139,6 +184,8 @@ class Connection:
target: Endpoint
name: str = ""
properties: dict[str, Any] = field(default_factory=dict)
type: str = "signal"
causality: str = "none"
def to_dict(self) -> dict[str, Any]:
return {
@@ -147,6 +194,8 @@ class Connection:
"target": self.target.to_dict(),
"name": self.name,
"properties": self.properties,
"type": self.type,
"causality": self.causality,
}
@classmethod
@@ -157,6 +206,8 @@ class Connection:
target=Endpoint.from_dict(data["target"]),
name=str(data.get("name", "")),
properties=dict(data.get("properties", {})),
type=str(data.get("type", "signal")),
causality=str(data.get("causality", "none")),
)
@@ -166,9 +217,23 @@ class Parameter:
name: str
type: str = "real"
value: str = "0"
quantity: str = ""
unit: str = ""
rows: int = 1
columns: int = 1
description: str = ""
def to_dict(self) -> dict[str, str]:
return {"id": self.id, "name": self.name, "type": self.type, "value": self.value}
def to_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"name": self.name,
"type": self.type,
"value": self.value,
"quantity": self.quantity,
"unit": self.unit,
"dimensions": {"rows": self.rows, "columns": self.columns},
"description": self.description,
}
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "Parameter":
@@ -176,11 +241,17 @@ class Parameter:
raise ValueError("Each component parameter must be an object")
if "id" not in data:
raise ValueError("Each component parameter must have an ID")
rows, columns = _dimensions(data.get("dimensions", {}), "Parameter")
return cls(
id=str(data["id"]),
name=str(data.get("name", "")),
type=str(data.get("type", "real")),
value=str(data.get("value", "0")),
quantity=str(data.get("quantity", "")),
unit=str(data.get("unit", "")),
rows=rows,
columns=columns,
description=str(data.get("description", "")),
)
@@ -299,8 +370,7 @@ class Component:
x: float = 0.0
y: float = 0.0
rotation: float = 0.0
inputs: list[Port] = field(default_factory=list)
outputs: list[Port] = field(default_factory=list)
ports: list[Port] = field(default_factory=list)
parameters: list[Parameter] = field(default_factory=list)
icon: Icon = field(default_factory=Icon)
properties: dict[str, Any] = field(default_factory=dict)
@@ -320,10 +390,7 @@ class Component:
"name": self.name,
"position": {"x": self.x, "y": self.y},
"rotation": self.rotation,
"interface": {
"inputs": [port.to_dict() for port in self.inputs],
"outputs": [port.to_dict() for port in self.outputs],
},
"interface": {"ports": [port.to_dict() for port in self.ports]},
"parameters": [parameter.to_dict() for parameter in self.parameters],
"icon": self.icon.to_dict(),
"properties": self.properties,
@@ -344,11 +411,19 @@ class Component:
if kind == "text":
raw_source = implementation.get("source", {})
equations = raw_source.get("equations", "")
declarations = raw_source.get("declarations", "")
initial_equations = raw_source.get("initialEquations", "")
parameters = data.get("parameters", raw_source.get("parameters", []))
if not isinstance(equations, str):
raise ValueError("Text component equations must be a string")
if not isinstance(declarations, str):
raise ValueError("Text component declarations must be a string")
if not isinstance(initial_equations, str):
raise ValueError("Text component initial equations must be a string")
source = {
"equations": equations,
"declarations": declarations,
"initialEquations": initial_equations,
}
if not isinstance(parameters, list):
raise ValueError("Component parameters must be a list")
@@ -358,8 +433,7 @@ class Component:
x=float(position.get("x", 0.0)),
y=float(position.get("y", 0.0)),
rotation=float(data.get("rotation", 0.0)),
inputs=[Port.from_dict(item) for item in interface.get("inputs", [])],
outputs=[Port.from_dict(item) for item in interface.get("outputs", [])],
ports=[Port.from_dict(item) for item in interface.get("ports", [])],
parameters=[Parameter.from_dict(item) for item in parameters],
icon=Icon.from_dict(data.get("icon")),
properties=dict(data.get("properties", {})),
@@ -445,6 +519,13 @@ class GraphDocument:
raise ValueError(f"Component {component.name!r} has duplicate parameter names")
if any(not name.strip() for name in parameter_names):
raise ValueError(f"Component {component.name!r} has an unnamed parameter")
if any(not parameter.type.strip() for parameter in component.parameters):
raise ValueError(f"Component {component.name!r} has an untyped parameter")
if any(
parameter.rows < 1 or parameter.columns < 1
for parameter in component.parameters
):
raise ValueError(f"Component {component.name!r} has invalid parameter dimensions")
if component.implementation_kind == "text" and component.graph.blocks:
raise ValueError(f"Text component {component.name} cannot contain a graph")
self._validate_graph(component)
@@ -456,16 +537,46 @@ class GraphDocument:
raise ValueError(
f"Component names inside {owner.name!r} must be unique"
)
input_ids = {port.id for port in owner.inputs}
output_ids = {port.id for port in owner.outputs}
if len(input_ids) != len(owner.inputs) or len(output_ids) != len(owner.outputs):
port_ids = {port.id for port in owner.ports}
if len(port_ids) != len(owner.ports):
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)
if port.orientation not in {"input", "output", "indifferent"}:
raise ValueError(f"Port {port.name!r} has an invalid orientation")
if port.orientation == "indifferent" and port.type != "power":
raise ValueError("Only power ports may have indifferent orientation")
if port.type == "power":
if port.domain not in {domain.id for domain in POWER_DOMAINS}:
raise ValueError(f"Power port {port.name!r} has an unknown domain")
if port.causality not in POWER_CAUSALITIES:
raise ValueError(f"Power port {port.name!r} has invalid causality")
if (
port.causality in MULTI_CONNECTION_POWER_CAUSALITIES
and not port.allows_multiple_connections
):
raise ValueError(
f"Power port {port.name!r} requires multiple connections "
f"for causality {port.causality!r}"
)
if not port.value_type.strip():
raise ValueError(f"Port {port.name!r} has no value type")
if port.rows < 1 or port.columns < 1:
raise ValueError(f"Port {port.name!r} has invalid dimensions")
for junction in owner.graph.junctions.values():
PortTypeRegistry.get(junction.type)
if junction.type == "power":
raise ValueError("Power bond connections cannot contain junctions")
endpoint_counts: dict[tuple[str, str, str], int] = {}
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:
junction = owner.graph.junctions.get(connection.source.junction)
if junction is None:
@@ -474,14 +585,30 @@ class GraphDocument:
)
source_port = Port(junction.id, "Junction", type=junction.type)
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")
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:
source = owner.graph.blocks.get(connection.source.block or "")
if source is None or connection.source.port not in {p.id for p in source.outputs}:
source_ports = (
[]
if source is None
else [
port
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}:
raise ValueError(f"Connection {connection.id} uses an unknown block output")
source_port = next(p for p in source.outputs if p.id == connection.source.port)
source_port = next(p for p in source_ports if p.id == connection.source.port)
if connection.target.junction is not None:
junction = owner.graph.junctions.get(connection.target.junction)
if junction is None:
@@ -495,16 +622,44 @@ class GraphDocument:
allows_multiple_connections=False,
)
elif connection.target.interface is not None:
if connection.target.interface not in output_ids:
target_ports = [
port
for port in owner.ports
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")
target_port = next(p for p in owner.outputs if p.id == connection.target.interface)
target_port = next(
port for port in target_ports if port.id == connection.target.interface
)
else:
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")
target_port = next(p for p in target.inputs if p.id == connection.target.port)
if not PortTypeRegistry.compatible(source_port.type, target_port.type):
target_port = next(
port for port in target_ports if port.id == connection.target.port
)
if not PortTypeRegistry.compatible(
source_port.type,
target_port.type,
source_port.domain,
target_port.domain,
):
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-junction"
if connection.source.junction is not None
@@ -567,6 +722,8 @@ def clone_component(source: Component) -> Component:
remap(connection.target),
connection.name,
deepcopy(connection.properties),
connection.type,
connection.causality,
)
for connection in current.graph.connections.values()
for new_id in [str(uuid4())]
@@ -599,30 +756,7 @@ def clone_component(source: Component) -> Component:
name=current.name,
x=current.x,
y=current.y,
inputs=[
Port(
port.id,
port.name,
port.x,
port.y,
deepcopy(port.properties),
port.type,
port.allows_multiple_connections,
)
for port in current.inputs
],
outputs=[
Port(
port.id,
port.name,
port.x,
port.y,
deepcopy(port.properties),
port.type,
port.allows_multiple_connections,
)
for port in current.outputs
],
ports=[Port.from_dict(port.to_dict()) for port in current.ports],
parameters=deepcopy(current.parameters),
icon=Icon.from_dict(current.icon.to_dict()),
properties=deepcopy(current.properties),

View File

@@ -0,0 +1,47 @@
"""Editable suggestions for signal and parameter physical metadata.
These values populate editable combo boxes; documents may use values not listed
here. They are suggestions rather than a validation registry.
"""
VALUE_TYPES = ("real", "integer", "boolean", "string")
QUANTITIES = (
"",
"Angle",
"AngularVelocity",
"Current",
"Energy",
"Force",
"Frequency",
"Length",
"Mass",
"Power",
"Pressure",
"Temperature",
"Time",
"Torque",
"Velocity",
"Voltage",
)
UNITS = (
"",
"1",
"A",
"Hz",
"J",
"K",
"N",
"N.m",
"Pa",
"V",
"W",
"deg",
"kg",
"m",
"m/s",
"rad",
"rad/s",
"s",
)

View File

@@ -14,6 +14,7 @@ class PortType:
class PortTypeRegistry:
_types = {
"signal": PortType("signal", "Signal", "A scalar signal connection"),
"power": PortType("power", "Power", "A two-variable power connection"),
}
@classmethod
@@ -28,5 +29,13 @@ class PortTypeRegistry:
raise ValueError(f"Unknown port type: {type_id}") from error
@classmethod
def compatible(cls, first: str, second: str) -> bool:
return cls.get(first).accepts(cls.get(second))
def compatible(
cls,
first: str,
second: str,
first_domain: str = "",
second_domain: str = "",
) -> bool:
if not cls.get(first).accepts(cls.get(second)):
return False
return first != "power" or first_domain == second_domain

View File

@@ -0,0 +1,49 @@
"""Editable power-port domain definitions used by the port editor."""
from dataclasses import dataclass
@dataclass(frozen=True)
class PowerDomain:
id: str
display_name: str
effort: str
flow: str
POWER_DOMAINS: tuple[PowerDomain, ...] = (
PowerDomain("power", "Power", "p.e", "p.f"),
)
def power_domain(domain_id: str) -> PowerDomain:
return next((domain for domain in POWER_DOMAINS if domain.id == domain_id), POWER_DOMAINS[0])
STANDARD_POWER_CAUSALITIES = (
"fixed flow out",
"fixed effort out",
"preferred flow out",
"preferred effort out",
"likes flow out",
"likes effort out",
"indifferent",
)
MULTI_CONNECTION_POWER_CAUSALITIES = (
"single effort in",
"single flow in",
)
POWER_CAUSALITIES = (
*STANDARD_POWER_CAUSALITIES,
*MULTI_CONNECTION_POWER_CAUSALITIES,
)
def power_causalities(allows_multiple_connections: bool = False) -> tuple[str, ...]:
"""Return the causalities available for one power port configuration."""
if allows_multiple_connections:
return POWER_CAUSALITIES
return STANDARD_POWER_CAUSALITIES

View File

@@ -1,9 +1,10 @@
from bedit.core.simulation.service import Simulation
from bedit.core.simulation.openmodelica import OpenModelicaInterface
from bedit.core.simulation.openmodelica import ModelBuildResult, OpenModelicaInterface
from bedit.core.simulation.results import (
BerSimulationResultsSerializer,
JsonSimulationResultsSerializer,
SimulationExecutionResult,
SimulationGraph,
SimulationResults,
SimulationResultsSerializer,
SimulationTrace,
@@ -11,9 +12,11 @@ from bedit.core.simulation.results import (
__all__ = [
"OpenModelicaInterface",
"ModelBuildResult",
"Simulation",
"SimulationResults",
"SimulationExecutionResult",
"SimulationGraph",
"SimulationResultsSerializer",
"SimulationTrace",
"BerSimulationResultsSerializer",

View File

@@ -59,9 +59,7 @@ def build_id_list(graph: dict[str, Any]) -> dict[str, Any]:
def visit(component: dict[str, Any]) -> None:
add(component, "component")
interface = component.get("interface", {})
for port in interface.get("inputs", []):
add(port, "port")
for port in interface.get("outputs", []):
for port in interface.get("ports", []):
add(port, "port")
implementation = component.get("implementation", {})
@@ -82,6 +80,7 @@ def emit_model(
id_list: dict[str, Any],
indent: int = 0,
connection_counts: dict[str, int] | None = None,
connection_signs: dict[str, list[float]] | None = None,
) -> str:
"""Emit a component and its nested definitions as Modelica source."""
@@ -94,33 +93,58 @@ def emit_model(
implementation_kind = implementation.get("kind")
nested_graph = implementation.get("graph", {})
port_counts = connection_counts or _interface_connection_counts(graph)
macros = _port_count_macros(graph, port_counts)
port_signs = connection_signs or _interface_connection_signs(graph)
macros = _port_connection_macros(graph, port_counts, port_signs)
if indent == 0 and _contains_power_port(graph):
lines.extend(
(
f"{body_indent}connector BondPort \"Bond graph power port\"",
f"{body_indent}\tReal e \"Effort variable\";",
f"{body_indent}\tflow Real f \"Flow variable\";",
f"{body_indent}end BondPort;",
)
)
if implementation_kind == "graph":
for block in nested_graph.get("blocks", []):
block_counts, block_signs = _block_connection_data(
nested_graph, block["id"]
)
lines.extend(
emit_model(
block,
{},
indent + 1,
_block_connection_counts(nested_graph, block["id"]),
block_counts,
block_signs,
)
.rstrip()
.splitlines()
)
interface = graph.get("interface", {})
for port in interface.get("inputs", []):
lines.append(_port_declaration(port, "input", indent + 1, macros))
for port in interface.get("outputs", []):
lines.append(_port_declaration(port, "output", indent + 1, macros))
for port in interface.get("ports", []):
direction = "output" if port.get("orientation") == "output" else "input"
lines.append(_port_declaration(port, direction, indent + 1, macros))
for parameter in graph.get("parameters", []):
parameter_type = modelica_type(parameter.get("type", "real"))
parameter_name = identifier(parameter["name"])
value = expand_bevalues(str(parameter.get("value", "0")), macros)
dimensions = _fixed_dimensions(parameter)
attributes = _quantity_unit_attributes(parameter)
lines.append(
f"{body_indent}parameter {parameter_type} {parameter_name} = {value};"
f"{body_indent}parameter {parameter_type} {parameter_name}"
f"{dimensions}{attributes} = {value};"
)
if implementation_kind == "text":
declarations = str(implementation.get("source", {}).get("declarations", ""))
declarations = expand_bevalues(declarations, macros)
lines.extend(
f"{body_indent}{line}" if line.strip() else ""
for line in declarations.splitlines()
)
if implementation_kind == "graph":
@@ -134,8 +158,9 @@ def emit_model(
f"{body_indent}{junction_type} {_junction_name(junction['id'])};"
)
lines.append(f"{indentation}equation")
if implementation_kind == "graph":
lines.append(f"{indentation}equation")
blocks = {block["id"]: block for block in nested_graph.get("blocks", [])}
junctions = {
junction["id"]: junction for junction in nested_graph.get("junctions", [])
@@ -148,11 +173,26 @@ def emit_model(
target = _endpoint_expression(
connection["target"], graph, blocks, junctions, endpoint_indices
)
# Connector types may require different equations in future.
lines.append(f"{body_indent}{target} = {source};")
if connection.get("type") == "power":
lines.append(f"{body_indent}connect({source}, {target});")
else:
lines.append(f"{body_indent}{target} = {source};")
else:
initial_equations = str(
implementation.get("source", {}).get("initialEquations", "")
)
initial_equations = expand_bevalues(initial_equations, macros)
equations = str(implementation.get("source", {}).get("equations", ""))
equations = expand_bevalues(equations, macros)
if initial_equations.strip():
lines.append(f"{indentation}initial equation")
lines.extend(
f"{body_indent}{line}" if line.strip() else ""
for line in initial_equations.splitlines()
)
lines.append(f"{indentation}equation")
lines.extend(
f"{body_indent}{line}" if line.strip() else ""
for line in equations.splitlines()
@@ -161,7 +201,6 @@ def emit_model(
lines.append(f"{indentation}end {model_name};")
return "\n".join(lines) + "\n"
def cleanup_graph(graph: dict[str, Any]) -> dict[str, Any]:
"""Remove annotations and UI-only data from a serialized component tree."""
@@ -192,14 +231,52 @@ def cleanup_graph(graph: dict[str, Any]) -> dict[str, Any]:
def _port_declaration(
port: dict[str, Any], direction: str, indent: int, macros: dict[str, str]
) -> str:
port_type = modelica_type(port.get("type", "signal"))
indentation = "\t" * indent
port_name = identifier(port["name"])
dimension = f"[${port_name}_N$]" if port.get("multipleConnections", False) else ""
declaration = f"{indentation}{direction} {port_type} {port_name}{dimension};"
dimensions = _port_dimensions(port, port_name)
if port.get("type") == "power":
return expand_bevalues(
f"{indentation}BondPort {port_name}{dimensions};", macros
)
port_type = modelica_type(port.get("valueType", "real"))
attributes = _quantity_unit_attributes(port)
declaration = (
f"{indentation}{direction} {port_type} {port_name}{dimensions}{attributes};"
)
return expand_bevalues(declaration, macros)
def _fixed_dimensions(item: dict[str, Any]) -> str:
dimensions = item.get("dimensions", {})
rows = max(1, int(dimensions.get("rows", 1)))
columns = max(1, int(dimensions.get("columns", 1)))
if rows == columns == 1:
return ""
if columns == 1:
return f"[{rows}]"
return f"[{rows},{columns}]"
def _port_dimensions(port: dict[str, Any], port_name: str) -> str:
fixed = _fixed_dimensions(port)
if not port.get("multipleConnections", False):
return fixed
entries = [f"${port_name}_N$"]
if fixed:
entries.extend(fixed[1:-1].split(","))
return f"[{','.join(entries)}]"
def _quantity_unit_attributes(item: dict[str, Any]) -> str:
attributes = []
for key in ("quantity", "unit"):
value = str(item.get(key, "")).strip()
if value:
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
attributes.append(f'{key}="{escaped}"')
return f"({', '.join(attributes)})" if attributes else ""
def _endpoint_expression(
endpoint: dict[str, Any],
owner: dict[str, Any],
@@ -244,16 +321,21 @@ def _index_array_endpoint(
return f"{expression}[{endpoint_indices[key]}]"
def _block_connection_counts(
def _block_connection_data(
graph: dict[str, Any], block_id: str
) -> dict[str, int]:
) -> tuple[dict[str, int], dict[str, list[float]]]:
counts: dict[str, int] = {}
signs: dict[str, list[float]] = {}
for connection in graph.get("connections", []):
for endpoint in (connection.get("source", {}), connection.get("target", {})):
for endpoint, sign in (
(connection.get("source", {}), -1.0),
(connection.get("target", {}), 1.0),
):
if endpoint.get("block") == block_id and endpoint.get("port"):
port_id = endpoint["port"]
counts[port_id] = counts.get(port_id, 0) + 1
return counts
signs.setdefault(port_id, []).append(sign)
return counts, signs
def _interface_connection_counts(component: dict[str, Any]) -> dict[str, int]:
@@ -269,19 +351,56 @@ def _interface_connection_counts(component: dict[str, Any]) -> dict[str, int]:
return counts
def _port_count_macros(
component: dict[str, Any], connection_counts: dict[str, int]
def _interface_connection_signs(component: dict[str, Any]) -> dict[str, list[float]]:
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return {}
signs: dict[str, list[float]] = {}
for connection in implementation.get("graph", {}).get("connections", []):
for endpoint, sign in (
(connection.get("source", {}), -1.0),
(connection.get("target", {}), 1.0),
):
port_id = endpoint.get("interface")
if port_id:
signs.setdefault(port_id, []).append(sign)
return signs
def _port_connection_macros(
component: dict[str, Any],
connection_counts: dict[str, int],
connection_signs: dict[str, list[float]],
) -> dict[str, str]:
macros: dict[str, str] = {}
interface = component.get("interface", {})
for port in (*interface.get("inputs", []), *interface.get("outputs", [])):
for port in interface.get("ports", []):
if port.get("multipleConnections", False):
macros[f"{identifier(port['name'])}_N"] = str(
connection_counts.get(port["id"], 0)
)
macro_name = identifier(port["name"])
port_id = port["id"]
macros[f"{macro_name}_N"] = str(connection_counts.get(port_id, 0))
signs = connection_signs.get(port_id, [])
macros[f"{macro_name}_S"] = "{" + ", ".join(
f"{sign:.1f}" for sign in signs
) + "}"
return macros
def _contains_power_port(component: dict[str, Any]) -> bool:
if any(
port.get("type") == "power"
for port in component.get("interface", {}).get("ports", [])
):
return True
implementation = component.get("implementation", {})
if implementation.get("kind") != "graph":
return False
return any(
_contains_power_port(block)
for block in implementation.get("graph", {}).get("blocks", [])
)
def expand_bevalues(text: str, values: dict[str, str]) -> str:
"""Replace BEdit ``$name$`` macros and reject unresolved composer values."""
@@ -296,8 +415,7 @@ def expand_bevalues(text: str, values: dict[str, str]) -> str:
def _find_port(component: dict[str, Any], port_id: str) -> dict[str, Any]:
interface = component.get("interface", {})
ports = [*interface.get("inputs", []), *interface.get("outputs", [])]
for port in ports:
for port in interface.get("ports", []):
if port.get("id") == port_id:
return port
raise ValueError(

View File

@@ -1,5 +1,7 @@
import json
import logging
import os
import re
import shlex
import shutil
import socket
@@ -48,6 +50,15 @@ class SimulationMessage:
text: str
@dataclass(frozen=True)
class ModelBuildResult:
"""OMC's model check summary and subsequent build response."""
check_summary: str
build_result: Any
diagnostics: str = ""
class OpenModelicaInterface:
"""Asynchronous, persistent interface to one OpenModelica session.
@@ -92,10 +103,42 @@ class OpenModelicaInterface:
"""Load and build one composed model as an ordered worker operation."""
def operation(omc, _temp_dir: Path):
loaded = omc.sendExpression(f"loadString({json.dumps(model)})")
if loaded is not True:
raise RuntimeError("OpenModelica could not load the composed model")
return omc.sendExpression(f"buildModel({model_name})")
capture = _LogCapture()
ompython_logger = logging.getLogger("OMPython")
previous_propagate = ompython_logger.propagate
ompython_logger.addHandler(capture)
ompython_logger.propagate = False
try:
unit_checking = omc.sendExpression(
'setCommandLineOptions("--unitChecking")'
)
if unit_checking is not True:
raise RuntimeError("OpenModelica could not enable unit checking")
loaded = omc.sendExpression(f"loadString({json.dumps(model)})")
if loaded is not True:
raise RuntimeError("OpenModelica could not load the composed model")
diagnostics = [_read_omc_diagnostics(omc)]
check_summary = omc.sendExpression(f"checkModel({model_name})")
diagnostics.append(_read_omc_diagnostics(omc))
if not isinstance(check_summary, str) or not check_summary.strip():
raise RuntimeError(
"OpenModelica did not return a model-check summary"
)
raw_build_result = omc.sendExpression(
f"buildModel({model_name})", parsed=False
)
build_result = _parse_modelica_string_array(raw_build_result)
diagnostics.append(_read_omc_diagnostics(omc))
diagnostics.extend(capture.messages)
diagnostic_text = "\n".join(
item.strip()
for item in diagnostics
if isinstance(item, str) and item.strip()
)
return ModelBuildResult(check_summary, build_result, diagnostic_text)
finally:
ompython_logger.removeHandler(capture)
ompython_logger.propagate = previous_propagate
self._submit("build model", operation, callback, error_callback)
@@ -247,6 +290,51 @@ class OpenModelicaInterface:
shutil.rmtree(temp_dir, ignore_errors=True)
def _read_omc_diagnostics(omc) -> str:
"""Read OMC diagnostics without sending multiline text through OMPython's parser."""
raw = omc.sendExpression("getErrorString()", parsed=False)
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
if not isinstance(raw, str):
return ""
text = raw.strip()
if not text:
return ""
try:
decoded = json.loads(text)
except json.JSONDecodeError:
return text
return decoded if isinstance(decoded, str) else text
class _LogCapture(logging.Handler):
"""Collect dependency log messages for forwarding through BEdit's logger."""
def __init__(self) -> None:
super().__init__(level=logging.WARNING)
self.messages: list[str] = []
def emit(self, record: logging.LogRecord) -> None:
self.messages.append(record.getMessage())
def _parse_modelica_string_array(raw: Any) -> tuple[str, ...]:
"""Parse the string array returned by ``buildModel`` in raw mode."""
if isinstance(raw, bytes):
raw = raw.decode("utf-8", errors="replace")
if not isinstance(raw, str):
raise RuntimeError(f"OpenModelica returned an invalid build result: {raw!r}")
values = tuple(
json.loads(token)
for token in re.findall(r'"(?:\\.|[^"\\])*"', raw)
)
if not values:
raise RuntimeError(f"OpenModelica returned an invalid build result: {raw!r}")
return values
def _deliver_callback(callback: Callable[[Any], None], value: Any) -> None:
try:
callback(value)
@@ -334,16 +422,140 @@ def _run_model_with_tcp(
f"OpenModelica did not create the expected result file {result_path.name!r}"
)
data = load_openmodelica_csv(result_path)
init_path = temp_dir / f"{Path(executable).stem}_init.xml"
csv_column_count = len(data)
data = _add_openmodelica_alias_signals(init_path, data)
signal_metadata = _load_openmodelica_signal_metadata(init_path, data)
log.info(
"Loaded %d result columns from %s", len(data), result_path.name
"Loaded %d result columns, restored %d OMC aliases, and found %d "
"unit definitions from %s",
csv_column_count,
len(data) - csv_column_count,
len(signal_metadata),
result_path.name,
)
return SimulationExecutionResult(
return_code=int(return_code),
result_file=str(result_path),
data=data,
signal_metadata=signal_metadata,
)
def _add_openmodelica_alias_signals(
path: Path, data: dict[str, list[float]]
) -> dict[str, list[float]]:
"""Restore result aliases exactly as described by OMC's init XML."""
try:
root = ET.parse(path).getroot()
except (OSError, ET.ParseError) as error:
raise RuntimeError(
f"Could not read OpenModelica aliases from {path.name}: {error}"
) from error
variables: dict[str, dict[str, str]] = {}
for scalar in root.findall(".//ScalarVariable"):
name = scalar.get("name", "")
if not name:
continue
value = next(iter(scalar), None)
variables[name] = {
"alias": scalar.get("alias", "noAlias"),
"target": scalar.get("aliasVariable", ""),
"variability": scalar.get("variability", ""),
"start": value.get("start", "") if value is not None else "",
}
restored = {name: list(values) for name, values in data.items()}
sample_count = len(next(iter(data.values()), []))
def resolve(name: str, visited: set[str] | None = None) -> list[float] | None:
if name in restored:
return restored[name]
variable = variables.get(name)
if variable is None:
return None
visited = set() if visited is None else visited
if name in visited:
return None
visited.add(name)
alias_kind = variable["alias"]
target = variable["target"]
if alias_kind in {"alias", "negatedAlias"} and target:
values = resolve(target, visited)
if values is None:
return None
return [-value for value in values] if alias_kind == "negatedAlias" else list(values)
if variable["variability"] not in {"parameter", "constant"}:
return None
try:
value = float(variable["start"])
except ValueError:
return None
return [value] * sample_count
for name, variable in variables.items():
if variable["alias"] not in {"alias", "negatedAlias"} or name in restored:
continue
values = resolve(name)
if values is not None:
restored[name] = values
return restored
def _load_openmodelica_signal_metadata(
path: Path, signal_names: dict[str, list[float]]
) -> dict[str, dict[str, str]]:
"""Read units reported by OMC in its generated initialization XML."""
try:
root = ET.parse(path).getroot()
except (OSError, ET.ParseError) as error:
raise RuntimeError(
f"Could not read OpenModelica unit metadata from {path.name}: {error}"
) from error
variables: dict[str, tuple[dict[str, str], str]] = {}
for scalar in root.findall(".//ScalarVariable"):
name = scalar.get("name", "")
if not name:
continue
value = next(iter(scalar), None)
metadata: dict[str, str] = {}
if value is not None:
for key in ("unit", "displayUnit", "quantity"):
attribute = value.get(key, "")
if attribute:
metadata[key] = attribute
description = scalar.get("description", "")
if description:
metadata["description"] = description
variables[name] = (metadata, scalar.get("aliasVariable", ""))
def resolve(name: str, visited: set[str] | None = None) -> dict[str, str]:
metadata, alias = variables.get(name, ({}, ""))
if metadata.get("unit") or not alias:
return dict(metadata)
visited = set() if visited is None else visited
if name in visited:
return dict(metadata)
visited.add(name)
inherited = resolve(alias, visited)
return {**inherited, **metadata}
metadata: dict[str, dict[str, str]] = {}
for name in signal_names:
if name not in variables:
continue
resolved = resolve(name)
if resolved:
metadata[name] = resolved
if "time" in signal_names:
metadata["time"] = {"unit": "s", "quantity": "Time"}
return metadata
def _accept_simulation_connection(
server: socket.socket, command_finished: Event
) -> socket.socket:

View File

@@ -4,6 +4,7 @@ import zlib
from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Any
from uuid import uuid4
import msgpack
@@ -25,6 +26,16 @@ class SimulationTrace:
properties: dict[str, Any] = field(default_factory=dict)
@dataclass
class SimulationGraph:
"""One graph workspace tab and its configured traces."""
id: str = field(default_factory=lambda: str(uuid4()))
title: str = "Graph 1"
x_axis: str = "time"
traces: list[SimulationTrace] = field(default_factory=list)
@dataclass
class SimulationResults:
"""Serializable state displayed by the standalone simulation window."""
@@ -33,7 +44,10 @@ class SimulationResults:
status: dict[str, Any] = field(default_factory=dict)
messages: list[dict[str, str]] = field(default_factory=list)
data: dict[str, list[float]] = field(default_factory=dict)
traces: list[SimulationTrace] = field(default_factory=list)
signal_metadata: dict[str, dict[str, str]] = field(default_factory=dict)
graphs: list[SimulationGraph] = field(
default_factory=lambda: [SimulationGraph()]
)
metadata: dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
@@ -44,7 +58,11 @@ class SimulationResults:
"status": dict(self.status),
"messages": [dict(message) for message in self.messages],
"data": {name: list(values) for name, values in self.data.items()},
"traces": [asdict(trace) for trace in self.traces],
"signalMetadata": {
name: dict(metadata)
for name, metadata in self.signal_metadata.items()
},
"graphs": [asdict(graph) for graph in self.graphs],
"metadata": dict(self.metadata),
}
@@ -55,7 +73,17 @@ class SimulationResults:
if data.get("version") != RESULTS_VERSION:
raise ValueError(f"Unsupported simulation-results version: {data.get('version')!r}")
try:
traces = [SimulationTrace(**trace) for trace in data.get("traces", [])]
graphs = [
SimulationGraph(
id=str(graph["id"]),
title=str(graph["title"]),
x_axis=str(graph.get("x_axis", "time")),
traces=[
SimulationTrace(**trace) for trace in graph.get("traces", [])
],
)
for graph in data.get("graphs", [])
]
return cls(
model_name=str(data.get("modelName", "")),
status=dict(data.get("status", {})),
@@ -64,10 +92,19 @@ class SimulationResults:
str(name): [float(value) for value in values]
for name, values in dict(data.get("data", {})).items()
},
traces=traces,
signal_metadata={
str(name): {
str(key): str(value)
for key, value in dict(metadata).items()
}
for name, metadata in dict(
data.get("signalMetadata", {})
).items()
},
graphs=graphs,
metadata=dict(data.get("metadata", {})),
)
except (TypeError, ValueError) as error:
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Malformed simulation-results data") from error
@@ -78,6 +115,7 @@ class SimulationExecutionResult:
return_code: int
result_file: str
data: dict[str, list[float]]
signal_metadata: dict[str, dict[str, str]] = field(default_factory=dict)
def load_openmodelica_csv(path: str | Path) -> dict[str, list[float]]:

View File

@@ -2,9 +2,11 @@ from collections.abc import Callable
from typing import Any
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.openmodelica import (
ErrorCallback,
ModelBuildResult,
OpenModelicaInterface,
ResultCallback,
SimulationMessage,
@@ -43,22 +45,45 @@ class Simulation:
graph: dict[str, Any],
callback: Callable[[str], None] | None = None,
error_callback: ErrorCallback | None = None,
message_callback: Callable[[SimulationMessage], None] | None = None,
) -> None:
"""Compose and retain the active graph's Modelica representation."""
self.model_path = None
# Create openmodelica model
result = compose_graph(graph)
self.last_composition_input = result.graph
self.id_list = result.objects_by_id
self.last_composition_output = result.modelica
self.model_name = result.model_name
self.compose_source(graph)
def _model_compiled(result):
log.info("Compiling OK: %s", result)
if not isinstance(result, ModelBuildResult):
failure = RuntimeError(
f"OpenModelica returned an invalid build response: {result!r}"
)
if error_callback is not None:
error_callback(failure)
else:
log.error("%s", failure)
return
log.info("%s", result.check_summary.strip())
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="info",
text=result.check_summary.strip(),
)
)
if result.diagnostics:
log.warning("OpenModelica build diagnostics:\n%s", result.diagnostics)
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="warning",
text=result.diagnostics,
)
)
log.info("Compiling OK: %s", result.build_result)
try:
self.model_path = str(result[0])
self.model_path = str(result.build_result[0])
except (IndexError, TypeError) as error:
failure = RuntimeError(
f"OpenModelica returned an invalid build result: {result!r}"
@@ -69,6 +94,14 @@ class Simulation:
else:
log.error("%s", failure)
return
if message_callback is not None:
message_callback(
SimulationMessage(
stream="build",
type="info",
text=f"Compiling OK: {result.build_result}",
)
)
if callback is not None:
callback(self.model_path)
@@ -79,6 +112,18 @@ class Simulation:
error_callback,
)
def compose_source(self, graph: dict[str, Any]) -> tuple[str, str]:
"""Compose Modelica source without asking OpenModelica to build it."""
infer_causality(graph)
result = compose_graph(graph)
self.last_composition_input = result.graph
self.id_list = result.objects_by_id
self.last_composition_output = result.modelica
self.model_name = result.model_name
# log.info("Composed OpenModelica model:\n%s", result.modelica)
return result.model_name, result.modelica
def run_simulation(
self,
graph: dict[str, Any],
@@ -114,7 +159,7 @@ class Simulation:
error_callback,
)
self.compose(graph, run_model, error_callback)
self.compose(graph, run_model, error_callback, message_callback)
def get_progress(self) -> SimulationProgress | None:
"""Return the most recently received simulation status."""

Binary file not shown.

View File

@@ -1,4 +1,6 @@
import sys
from collections.abc import Sequence
from pathlib import Path
from PySide6.QtCore import QCoreApplication, Qt
from PySide6.QtGui import QColor, QPalette
@@ -38,14 +40,26 @@ def apply_light_theme(app: QApplication) -> None:
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.setOrganizationName("BEdit")
QCoreApplication.setApplicationVersion("0.1.0")
app = QApplication(sys.argv)
app = QApplication(list(sys.argv if argv is None else argv))
app.setApplicationDisplayName("BEdit")
apply_light_theme(app)
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()
return app.exec()

View File

@@ -304,6 +304,20 @@ class RenameConnectionCommand(QUndoCommand):
self.controller._rename_connection(self.owner_id, self.connection_id, self.old)
class RenameDocumentCommand(QUndoCommand):
def __init__(self, controller, old: str, new: str) -> None:
super().__init__("Rename document")
self.controller = controller
self.old = old
self.new = new
def redo(self) -> None:
self.controller._rename_document(self.new)
def undo(self) -> None:
self.controller._rename_document(self.old)
class DeleteSelectionCommand(QUndoCommand):
def __init__(
self,
@@ -311,24 +325,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 +347,7 @@ class DeleteSelectionCommand(QUndoCommand):
self.owner_id,
self.blocks,
self.connections,
self.inputs,
self.outputs,
self.ports,
)
@@ -361,7 +371,6 @@ class PasteSelectionCommand(QUndoCommand):
self.blocks,
self.connections,
[],
[],
)
def undo(self) -> None:
@@ -370,7 +379,6 @@ class PasteSelectionCommand(QUndoCommand):
set(self.blocks),
set(self.connections),
set(),
set(),
)

View File

@@ -23,6 +23,7 @@ from bedit.gui.controllers.commands import (
MoveInterfacePortCommand,
PasteSelectionCommand,
RenameConnectionCommand,
RenameDocumentCommand,
RenameInterfacePortCommand,
ReplaceSourceCommand,
RotateComponentsCommand,
@@ -40,14 +41,17 @@ 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
from bedit.gui.preferences import application_settings
class DocumentController(QObject):
documentReset = Signal()
documentOpenedChanged = Signal(bool)
documentNameChanged = Signal(str)
activeGraphChanged = Signal()
componentAdded = Signal(str)
componentRemoved = Signal(str)
@@ -168,7 +172,13 @@ class DocumentController(QObject):
name=self._available_component_name(base_name, self.document.roots.values(), number),
implementation_kind=kind,
icon=Icon(text="Graph" if kind == "graph" else "Text"),
source={"equations": ""} if kind == "text" else {},
source={
"declarations": "",
"initialEquations": "",
"equations": "",
}
if kind == "text"
else {},
)
self.undo_stack.push(AddComponentCommand(self, None, component))
self.activate_component(component.id)
@@ -187,7 +197,13 @@ class DocumentController(QObject):
name=self._available_component_name(base_name, owner.graph.blocks.values(), number),
implementation_kind=kind,
icon=Icon(text="Graph" if kind == "graph" else "Text"),
source={"equations": ""} if kind == "text" else {},
source={
"declarations": "",
"initialEquations": "",
"equations": "",
}
if kind == "text"
else {},
)
self.undo_stack.push(AddComponentCommand(self, owner_id, component))
return component.id
@@ -214,10 +230,19 @@ class DocumentController(QObject):
{component_id: component},
connections,
[],
[],
)
)
def rename_document(self, name: str) -> None:
if self.document is None:
return
name = name.strip()
if not name:
raise ValueError("The document name cannot be empty")
old = str(self.document.metadata.get("name") or "Current Document")
if name != old:
self.undo_stack.push(RenameDocumentCommand(self, old, name))
def add_component_copy(self, source: Component, position: QPointF) -> str:
if self.active_component is None or self.active_component.implementation_kind != "graph":
raise ValueError("Open a graph component before placing components")
@@ -273,7 +298,12 @@ class DocumentController(QObject):
target_port = self._port_for_endpoint(target, "target")
if source_port is None or target_port is None:
raise ValueError("A connection endpoint no longer exists")
if not PortTypeRegistry.compatible(source_port.type, target_port.type):
if not PortTypeRegistry.compatible(
source_port.type,
target_port.type,
source_port.domain,
target_port.domain,
):
raise ValueError(f"Cannot connect {source_port.type!r} to {target_port.type!r}")
if not self.endpoint_accepts_connection(source, "source"):
raise ValueError(
@@ -290,6 +320,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
@@ -307,6 +338,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"] = [
@@ -321,6 +354,8 @@ class DocumentController(QObject):
Endpoint(junction=junction.id),
original.name,
first_properties,
original.type,
original.causality,
)
second = Connection(
str(uuid4()),
@@ -328,6 +363,8 @@ class DocumentController(QObject):
original.target,
"",
second_properties,
original.type,
original.causality,
)
self.undo_stack.push(
SplitConnectionCommand(
@@ -425,8 +462,16 @@ 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(
self,
progress_callback=None,
@@ -437,6 +482,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,
@@ -445,6 +491,37 @@ class DocumentController(QObject):
error_callback,
)
def _infer_active_graph_causality(
self, component: Component, *, emit_reset: bool = True
) -> None:
"""Infer causality and copy the derived values into the live model."""
inferred = infer_causality(component.to_dict())
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()
if emit_reset:
self.documentReset.emit()
def set_route_waypoints(self, item_kind: str, item_id: str, waypoints: list[QPointF]) -> None:
item = (
self.active_graph.connections
@@ -554,12 +631,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 = [
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
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
)
@@ -583,12 +674,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
@@ -603,7 +695,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)
@@ -634,19 +726,26 @@ 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,
parameters: list[Parameter],
) -> None:
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]
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):
@@ -666,30 +765,29 @@ class DocumentController(QObject):
)
if (
connection.source.block == component.id
and connection.source.port not in output_ids
and connection.source.port not in source_ids
):
raise ValueError(
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,
"initialEquations": initial_equations,
},
"parameters": [parameter.to_dict() for parameter in parameters],
}
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()
@@ -700,8 +798,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:
@@ -716,8 +813,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),
}
@@ -729,8 +825,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,
}
@@ -739,8 +834,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))
@@ -810,16 +904,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}
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():
@@ -830,7 +927,7 @@ class DocumentController(QObject):
raise ValueError("An input cannot be removed or reoriented while connected")
if (
connection.source.block == component_id
and connection.source.port not in output_ids
and connection.source.port not in source_ids
):
raise ValueError("An output cannot be removed or reoriented while connected")
for connection in component.graph.connections.values():
@@ -840,21 +937,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))
@@ -884,8 +978,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":
@@ -896,8 +989,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 = {
@@ -908,9 +1001,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(
@@ -918,8 +1010,7 @@ class DocumentController(QObject):
component.id,
blocks,
connections,
inputs,
outputs,
ports,
)
)
@@ -957,6 +1048,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:
@@ -1017,6 +1110,8 @@ class DocumentController(QObject):
),
name=source.name,
properties=properties,
type=source.type,
causality=source.causality,
)
connections[connection.id] = connection
@@ -1095,6 +1190,10 @@ class DocumentController(QObject):
def _insert_connection(self, owner_id: str, connection: Connection) -> None:
self._graph_for(owner_id).connections[connection.id] = connection
if self.document is not None and self._infer_causality_on_connection_change():
owner = self.document.find_component(owner_id)
if owner is not None:
self._infer_active_graph_causality(owner, emit_reset=False)
if owner_id == self.active_component_id:
self.connectionAdded.emit(connection.id)
self.documentReset.emit()
@@ -1131,10 +1230,20 @@ class DocumentController(QObject):
def _remove_connection(self, owner_id: str, connection_id: str) -> None:
self._graph_for(owner_id).connections.pop(connection_id, None)
if self.document is not None and self._infer_causality_on_connection_change():
owner = self.document.find_component(owner_id)
if owner is not None:
self._infer_active_graph_causality(owner, emit_reset=False)
if owner_id == self.active_component_id:
self.connectionRemoved.emit(connection_id)
self.documentReset.emit()
@staticmethod
def _infer_causality_on_connection_change() -> bool:
return application_settings().value(
"bondGraph/inferCausalityOnConnectionChange", True, type=bool
)
def _insert_annotation(self, owner_id: str, annotation: Annotation) -> None:
self._graph_for(owner_id).annotations[annotation.id] = annotation
if owner_id == self.active_component_id:
@@ -1232,9 +1341,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()
@@ -1244,8 +1353,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()
@@ -1255,7 +1363,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()
@@ -1265,7 +1373,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()
@@ -1277,6 +1385,12 @@ class DocumentController(QObject):
connection.name = name
self.documentReset.emit()
def _rename_document(self, name: str) -> None:
if self.document is None:
return
self.document.metadata["name"] = name
self.documentNameChanged.emit(name)
def _replace_source(self, component_id: str, source: dict) -> None:
if self.document is None:
return
@@ -1296,8 +1410,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()
@@ -1328,8 +1441,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
@@ -1352,8 +1464,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()
@@ -1364,8 +1475,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
@@ -1377,10 +1487,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
@@ -1395,8 +1503,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", [])

View File

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

View File

@@ -1,10 +1,11 @@
from copy import deepcopy
from uuid import uuid4
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QDialog, QListWidgetItem, QMessageBox
from bedit.core.model import Component, Parameter
from bedit.core.physical_types import QUANTITIES, UNITS
from bedit.gui.generated.ui_parameter_options_dialog import Ui_ParameterOptionsDialog
@@ -14,6 +15,8 @@ PARAMETER_ROLE = Qt.ItemDataRole.UserRole
class ParameterOptionsDialog(QDialog):
"""Editor for parameters shared by graph and text components."""
edited = Signal()
def __init__(self, component: Component, parent=None, *, read_only: bool = False) -> None:
super().__init__(parent)
self.ui = Ui_ParameterOptionsDialog()
@@ -22,19 +25,31 @@ class ParameterOptionsDialog(QDialog):
self.parameters = deepcopy(component.parameters)
self.read_only = read_only
self._loading = False
self.ui.quantityCombo.addItems(QUANTITIES)
self.ui.unitCombo.addItems(UNITS)
self.ui.parameterList.currentRowChanged.connect(self._load_current)
self.ui.addParameterButton.clicked.connect(self.add_parameter)
self.ui.removeParameterButton.clicked.connect(self.remove_parameter)
self.ui.nameEdit.textEdited.connect(self._store_current)
self.ui.typeEdit.textEdited.connect(self._store_current)
self.ui.typeCombo.currentTextChanged.connect(self._store_current)
self.ui.valueEdit.textEdited.connect(self._store_current)
self.ui.quantityCombo.currentTextChanged.connect(self._store_current)
self.ui.unitCombo.currentTextChanged.connect(self._store_current)
self.ui.rowsSpin.valueChanged.connect(self._store_current)
self.ui.columnsSpin.valueChanged.connect(self._store_current)
self.ui.descriptionEdit.textChanged.connect(self._store_current)
self.ui.parameterSplitter.setSizes([250, 370])
if read_only:
self.ui.addParameterButton.setEnabled(False)
self.ui.removeParameterButton.setEnabled(False)
self.ui.nameEdit.setReadOnly(True)
self.ui.typeEdit.setReadOnly(True)
self.ui.typeCombo.setEnabled(False)
self.ui.valueEdit.setReadOnly(True)
self.ui.quantityCombo.setEnabled(False)
self.ui.unitCombo.setEnabled(False)
self.ui.rowsSpin.setEnabled(False)
self.ui.columnsSpin.setEnabled(False)
self.ui.descriptionEdit.setReadOnly(True)
self._rebuild_list(0 if self.parameters else -1)
def _rebuild_list(self, row: int = -1) -> None:
@@ -51,12 +66,20 @@ class ParameterOptionsDialog(QDialog):
if 0 <= row < len(self.parameters):
parameter = self.parameters[row]
self.ui.nameEdit.setText(parameter.name)
self.ui.typeEdit.setText(parameter.type)
self.ui.typeCombo.setCurrentText(parameter.type)
self.ui.valueEdit.setText(parameter.value)
self.ui.quantityCombo.setCurrentText(parameter.quantity)
self.ui.unitCombo.setCurrentText(parameter.unit)
self.ui.rowsSpin.setValue(parameter.rows)
self.ui.columnsSpin.setValue(parameter.columns)
self.ui.descriptionEdit.setPlainText(parameter.description)
else:
self.ui.nameEdit.clear()
self.ui.typeEdit.clear()
self.ui.typeCombo.setCurrentText("")
self.ui.valueEdit.clear()
self.ui.quantityCombo.setCurrentText("")
self.ui.unitCombo.setCurrentText("")
self.ui.descriptionEdit.clear()
self._loading = False
self._update_enabled()
@@ -64,8 +87,13 @@ class ParameterOptionsDialog(QDialog):
enabled = self.ui.parameterList.currentRow() >= 0
self.ui.removeParameterButton.setEnabled(enabled and not self.read_only)
self.ui.nameEdit.setEnabled(enabled)
self.ui.typeEdit.setEnabled(enabled)
self.ui.typeCombo.setEnabled(enabled and not self.read_only)
self.ui.valueEdit.setEnabled(enabled)
self.ui.quantityCombo.setEnabled(enabled and not self.read_only)
self.ui.unitCombo.setEnabled(enabled and not self.read_only)
self.ui.rowsSpin.setEnabled(enabled and not self.read_only)
self.ui.columnsSpin.setEnabled(enabled and not self.read_only)
self.ui.descriptionEdit.setEnabled(enabled)
def _store_current(self) -> None:
row = self.ui.parameterList.currentRow()
@@ -73,11 +101,23 @@ class ParameterOptionsDialog(QDialog):
return
parameter = self.parameters[row]
parameter.name = self.ui.nameEdit.text()
parameter.type = self.ui.typeEdit.text()
parameter.type = self.ui.typeCombo.currentText().strip() or "real"
parameter.value = self.ui.valueEdit.text()
parameter.quantity = self.ui.quantityCombo.currentText().strip()
parameter.unit = self.ui.unitCombo.currentText().strip()
parameter.rows = self.ui.rowsSpin.value()
parameter.columns = self.ui.columnsSpin.value()
parameter.description = self.ui.descriptionEdit.toPlainText()
self.ui.parameterList.item(row).setText(
f"{parameter.name} [{parameter.type}] = {parameter.value}"
)
self.edited.emit()
def set_parameters(self, parameters: list[Parameter]) -> None:
self._loading = True
self.parameters = deepcopy(parameters)
self._loading = False
self._rebuild_list(0 if self.parameters else -1)
def add_parameter(self) -> None:
self.parameters.append(
@@ -89,12 +129,14 @@ class ParameterOptionsDialog(QDialog):
self._rebuild_list(len(self.parameters) - 1)
self.ui.nameEdit.selectAll()
self.ui.nameEdit.setFocus()
self.edited.emit()
def remove_parameter(self) -> None:
row = self.ui.parameterList.currentRow()
if row >= 0:
self.parameters.pop(row)
self._rebuild_list(min(row, len(self.parameters) - 1))
self.edited.emit()
def accept(self) -> None:
self._store_current()
@@ -106,3 +148,12 @@ class ParameterOptionsDialog(QDialog):
QMessageBox.warning(self, "Invalid parameter", "Parameter names must be unique.")
return
super().accept()
class ParameterEditor(ParameterOptionsDialog):
"""The parameter options editor embedded without dialog buttons."""
def __init__(self, parent=None) -> None:
super().__init__(Component(id="embedded-parameter-editor", name="Parameters"), parent)
self.setWindowFlags(Qt.WindowType.Widget)
self.ui.buttonBox.hide()

View File

@@ -1,10 +1,12 @@
from copy import deepcopy
from uuid import uuid4
from PySide6.QtCore import Qt
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QListWidgetItem, QMessageBox
from bedit.core.model import Component, Port
from bedit.core.physical_types import QUANTITIES, UNITS
from bedit.core.power_domains import POWER_DOMAINS, power_causalities, power_domain
from bedit.core.port_types import PortTypeRegistry
from bedit.gui.generated.ui_port_options_dialog import Ui_PortOptionsDialog
@@ -15,28 +17,45 @@ PORT_ROLE = Qt.ItemDataRole.UserRole
class PortOptionsDialog(QDialog):
"""Unified editor for a component's typed, oriented ports."""
edited = Signal()
def __init__(self, component: Component, parent=None, *, read_only: bool = False) -> None:
super().__init__(parent)
self.ui = Ui_PortOptionsDialog()
self.ui.setupUi(self)
self.setWindowTitle(f"Port Options — {component.name}")
self.ports: list[tuple[Port, str]] = [
*((deepcopy(port), "input") for port in component.inputs),
*((deepcopy(port), "output") for port in component.outputs),
]
self.read_only = read_only
self.ports = deepcopy(component.ports)
self._loading = False
self.ui.typeCombo.clear()
for port_type in PortTypeRegistry.all():
self.ui.typeCombo.addItem(port_type.display_name, port_type.id)
self.ui.quantityCombo.addItems(QUANTITIES)
self.ui.unitCombo.addItems(UNITS)
self.ui.orientationCombo.setItemData(0, "input")
self.ui.orientationCombo.setItemData(1, "output")
self.ui.orientationCombo.setItemData(2, "indifferent")
for domain in POWER_DOMAINS:
self.ui.domainCombo.addItem(domain.display_name, domain.id)
self._refresh_causality_options()
self.ui.portList.currentRowChanged.connect(self._load_current)
self.ui.addPortButton.clicked.connect(self.add_port)
self.ui.removePortButton.clicked.connect(self.remove_port)
self.ui.nameEdit.textEdited.connect(self._store_current)
self.ui.typeCombo.currentIndexChanged.connect(self._store_current)
self.ui.typeCombo.currentIndexChanged.connect(self._port_type_changed)
self.ui.orientationCombo.currentIndexChanged.connect(self._store_current)
self.ui.multipleConnectionsCheckBox.toggled.connect(self._store_current)
self.ui.multipleConnectionsCheckBox.toggled.connect(
self._multiple_connections_changed
)
self.ui.valueTypeCombo.currentTextChanged.connect(self._store_current)
self.ui.quantityCombo.currentTextChanged.connect(self._store_current)
self.ui.unitCombo.currentTextChanged.connect(self._store_current)
self.ui.rowsSpin.valueChanged.connect(self._store_current)
self.ui.columnsSpin.valueChanged.connect(self._store_current)
self.ui.descriptionEdit.textChanged.connect(self._store_current)
self.ui.domainCombo.currentIndexChanged.connect(self._power_domain_changed)
self.ui.causalityCombo.currentTextChanged.connect(self._store_current)
self.ui.powerDescriptionEdit.textChanged.connect(self._store_current)
self.ui.portSplitter.setSizes([250, 370])
if read_only:
self.ui.addPortButton.setEnabled(False)
@@ -45,22 +64,15 @@ class PortOptionsDialog(QDialog):
self.ui.typeCombo.setEnabled(False)
self.ui.orientationCombo.setEnabled(False)
self.ui.multipleConnectionsCheckBox.setEnabled(False)
self.ui.typeOptionsStack.setEnabled(False)
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setText("Close")
self.ui.buttonBox.button(QDialogButtonBox.StandardButton.Cancel).hide()
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 == "input"]
@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:
self.ui.portList.clear()
for port, orientation in self.ports:
item = QListWidgetItem(f"{port.name} [{orientation}, {port.type}]")
for port in self.ports:
item = QListWidgetItem(f"{port.name} [{port.orientation}, {port.type}]")
item.setData(PORT_ROLE, port.id)
self.ui.portList.addItem(item)
self.ui.portList.setCurrentRow(min(row, len(self.ports) - 1))
@@ -70,38 +82,125 @@ class PortOptionsDialog(QDialog):
self._loading = True
enabled = 0 <= row < len(self.ports)
if enabled:
port, orientation = self.ports[row]
port = self.ports[row]
self.ui.nameEdit.setText(port.name)
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(
port.allows_multiple_connections
)
self.ui.valueTypeCombo.setCurrentText(port.value_type)
self.ui.quantityCombo.setCurrentText(port.quantity)
self.ui.unitCombo.setCurrentText(port.unit)
self.ui.rowsSpin.setValue(port.rows)
self.ui.columnsSpin.setValue(port.columns)
self.ui.descriptionEdit.setPlainText(port.description)
self.ui.domainCombo.setCurrentIndex(self.ui.domainCombo.findData(port.domain))
self._refresh_causality_options(
port.causality,
port_type=port.type,
allows_multiple_connections=port.allows_multiple_connections,
)
self.ui.causalityCombo.setCurrentText(port.causality)
self.ui.powerDescriptionEdit.setPlainText(port.description)
self._show_type_options(port.type)
else:
self.ui.nameEdit.clear()
self.ui.descriptionEdit.clear()
self._loading = False
self._update_enabled()
def _update_enabled(self) -> None:
enabled = self.ui.portList.currentRow() >= 0
self.ui.removePortButton.setEnabled(enabled)
self.ui.removePortButton.setEnabled(enabled and not self.read_only)
self.ui.nameEdit.setEnabled(enabled)
self.ui.typeCombo.setEnabled(enabled)
self.ui.orientationCombo.setEnabled(enabled)
self.ui.multipleConnectionsCheckBox.setEnabled(enabled)
self.ui.typeCombo.setEnabled(enabled and not self.read_only)
self.ui.orientationCombo.setEnabled(enabled and not self.read_only)
self.ui.multipleConnectionsCheckBox.setEnabled(enabled and not self.read_only)
self.ui.typeOptionsStack.setEnabled(enabled and not self.read_only)
def _port_type_changed(self) -> None:
port_type = self.ui.typeCombo.currentData()
indifferent_item = self.ui.orientationCombo.model().item(2)
indifferent_item.setEnabled(port_type == "power")
if port_type != "power" and self.ui.orientationCombo.currentData() == "indifferent":
self.ui.orientationCombo.setCurrentIndex(0)
self._refresh_causality_options(port_type=port_type)
self._show_type_options(port_type)
self._store_current()
def _multiple_connections_changed(self) -> None:
self._refresh_causality_options()
self._store_current()
def _refresh_causality_options(
self,
selected: str | None = None,
*,
port_type: str | None = None,
allows_multiple_connections: bool | None = None,
) -> None:
selected = selected or self.ui.causalityCombo.currentText() or "indifferent"
port_type = port_type or self.ui.typeCombo.currentData()
if allows_multiple_connections is None:
allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked()
choices = power_causalities(
port_type == "power" and allows_multiple_connections
)
self.ui.causalityCombo.blockSignals(True)
self.ui.causalityCombo.clear()
self.ui.causalityCombo.addItems(choices)
self.ui.causalityCombo.setCurrentText(
selected if selected in choices else "indifferent"
)
self.ui.causalityCombo.blockSignals(False)
def _show_type_options(self, port_type: str) -> None:
self.ui.orientationCombo.model().item(2).setEnabled(port_type == "power")
page = {
"signal": self.ui.signalOptionsPage,
"power": self.ui.powerOptionsPage,
}.get(port_type, self.ui.unsupportedTypePage)
self.ui.typeOptionsStack.setCurrentWidget(page)
def _power_domain_changed(self) -> None:
domain = power_domain(self.ui.domainCombo.currentData())
self.ui.effortValueLabel.setText(domain.effort)
self.ui.flowValueLabel.setText(domain.flow)
self._store_current()
def _store_current(self) -> None:
row = self.ui.portList.currentRow()
if self._loading or not (0 <= row < len(self.ports)):
return
port, _orientation = self.ports[row]
port = self.ports[row]
port.name = self.ui.nameEdit.text()
port.type = self.ui.typeCombo.currentData()
port.allows_multiple_connections = self.ui.multipleConnectionsCheckBox.isChecked()
self.ports[row] = (port, self.ui.orientationCombo.currentData())
self.ui.portList.item(row).setText(
f"{port.name} [{self.ports[row][1]}, {port.type}]"
port.value_type = self.ui.valueTypeCombo.currentText().strip() or "real"
port.quantity = self.ui.quantityCombo.currentText().strip()
port.unit = self.ui.unitCombo.currentText().strip()
port.rows = self.ui.rowsSpin.value()
port.columns = self.ui.columnsSpin.value()
port.domain = self.ui.domainCombo.currentData() or "power"
port.causality = self.ui.causalityCombo.currentText()
port.description = (
self.ui.powerDescriptionEdit.toPlainText()
if port.type == "power"
else self.ui.descriptionEdit.toPlainText()
)
orientation = self.ui.orientationCombo.currentData()
port.orientation = orientation
self.ui.portList.item(row).setText(f"{port.name} [{port.orientation}, {port.type}]")
self.edited.emit()
def set_ports(self, ports: list[Port]) -> None:
self._loading = True
self.ports = deepcopy(ports)
self._loading = False
self._rebuild_list(0 if self.ports else -1)
def add_port(self) -> None:
port = Port(
@@ -110,20 +209,31 @@ class PortOptionsDialog(QDialog):
properties={"iconPosition": {"x": 0.0, "y": 0.0}},
type="signal",
)
self.ports.append((port, "input"))
self.ports.append(port)
self._rebuild_list(len(self.ports) - 1)
self.ui.nameEdit.selectAll()
self.ui.nameEdit.setFocus()
self.edited.emit()
def remove_port(self) -> None:
row = self.ui.portList.currentRow()
if row >= 0:
self.ports.pop(row)
self._rebuild_list(min(row, len(self.ports) - 1))
self.edited.emit()
def accept(self) -> None:
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.")
return
super().accept()
class PortEditor(PortOptionsDialog):
"""The port options editor embedded without dialog confirmation buttons."""
def __init__(self, parent=None) -> None:
super().__init__(Component(id="embedded-port-editor", name="Ports"), parent)
self.setWindowFlags(Qt.WindowType.Widget)
self.ui.buttonBox.hide()

View File

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

View File

@@ -1,20 +1,12 @@
from copy import deepcopy
from uuid import uuid4
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QCheckBox, QComboBox, QHeaderView, QTableWidgetItem, QWidget
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QWidget
from bedit.core.model import Parameter, Port
from bedit.core.port_types import PortTypeRegistry
from bedit.gui.generated.ui_text_definition_editor import Ui_TextDefinitionEditor
ID_ROLE = Qt.ItemDataRole.UserRole
PROPERTIES_ROLE = Qt.ItemDataRole.UserRole + 1
class TextDefinitionEditor(QWidget):
"""Editor for a text component's equations, ports, and parameters."""
"""Editor for a text component's Modelica source, ports, and parameters."""
modifiedChanged = Signal(bool)
definitionEdited = Signal()
@@ -25,24 +17,14 @@ class TextDefinitionEditor(QWidget):
self.ui.setupUi(self)
self._modified = False
self._loading = False
self.ui.portsTable.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.ui.parametersTable.horizontalHeader().setSectionResizeMode(
QHeaderView.ResizeMode.Stretch
)
self.ui.columnSplitter.setSizes([560, 340])
self.ui.sourceSplitter.setSizes([180, 180, 240])
self.ui.definitionSplitter.setSizes([300, 300])
self.ui.declarationsEdit.textChanged.connect(self._mark_modified)
self.ui.initialEquationsEdit.textChanged.connect(self._mark_modified)
self.ui.equationsEdit.textChanged.connect(self._mark_modified)
self.ui.portsTable.cellChanged.connect(self._symbols_modified)
self.ui.parametersTable.cellChanged.connect(self._symbols_modified)
self.ui.addPortButton.clicked.connect(self.add_port)
self.ui.removePortButton.clicked.connect(self.remove_port)
self.ui.addParameterButton.clicked.connect(self.add_parameter)
self.ui.removeParameterButton.clicked.connect(self.remove_parameter)
self.ui.portsTable.itemSelectionChanged.connect(self._update_buttons)
self.ui.parametersTable.itemSelectionChanged.connect(self._update_buttons)
self._update_buttons()
self.ui.portEditor.edited.connect(self._symbols_modified)
self.ui.parameterEditor.edited.connect(self._symbols_modified)
@property
def is_modified(self) -> bool:
@@ -53,63 +35,38 @@ class TextDefinitionEditor(QWidget):
return self.ui.equationsEdit.toPlainText()
@property
def ports(self) -> tuple[list[Port], list[Port]]:
inputs: list[Port] = []
outputs: list[Port] = []
for row in range(self.ui.portsTable.rowCount()):
name_item = self.ui.portsTable.item(row, 0)
type_combo = self.ui.portsTable.cellWidget(row, 1)
orientation_combo = self.ui.portsTable.cellWidget(row, 2)
multiple_check = self.ui.portsTable.cellWidget(row, 3)
port = Port(
id=name_item.data(ID_ROLE),
name=name_item.text().strip(),
type=type_combo.currentData(),
properties=deepcopy(name_item.data(PROPERTIES_ROLE) or {}),
allows_multiple_connections=multiple_check.isChecked(),
)
target = inputs if orientation_combo.currentData() == "input" else outputs
target.append(port)
return inputs, outputs
def declarations(self) -> str:
return self.ui.declarationsEdit.toPlainText()
@property
def initial_equations(self) -> str:
return self.ui.initialEquationsEdit.toPlainText()
@property
def ports(self) -> list[Port]:
return self.ui.portEditor.ports
@property
def parameters(self) -> list[Parameter]:
table = self.ui.parametersTable
return [
Parameter(
id=table.item(row, 0).data(ID_ROLE),
name=table.item(row, 0).text().strip(),
type=table.item(row, 1).text().strip(),
value=table.item(row, 2).text(),
)
for row in range(table.rowCount())
]
return self.ui.parameterEditor.parameters
def set_definition(
self,
declarations: str,
initial_equations: str,
equations: str,
inputs: list[Port],
outputs: list[Port],
ports: list[Port],
parameters: list[Parameter],
) -> None:
self._loading = True
self.ui.declarationsEdit.setPlainText(declarations)
self.ui.initialEquationsEdit.setPlainText(initial_equations)
self.ui.equationsEdit.setPlainText(equations)
self.ui.portsTable.setRowCount(0)
for port in inputs:
self._append_port(port, "input")
for port in outputs:
self._append_port(port, "output")
self.ui.parametersTable.setRowCount(0)
for parameter in parameters:
self._append_parameter(parameter)
self.ui.equationsEdit.set_symbols(
[port.name for port in inputs],
[port.name for port in outputs],
[parameter.name for parameter in parameters],
)
self.ui.portEditor.set_ports(ports)
self.ui.parameterEditor.set_parameters(parameters)
self._set_editor_symbols(ports, parameters)
self._loading = False
self.set_modified(False)
self._update_buttons()
def set_modified(self, modified: bool) -> None:
if self._modified != modified:
@@ -127,91 +84,27 @@ class TextDefinitionEditor(QWidget):
self._mark_modified()
def _refresh_editor_symbols(self) -> None:
inputs, outputs = self.ports
self.ui.equationsEdit.set_symbols(
[port.name for port in inputs],
[port.name for port in outputs],
[parameter.name for parameter in self.parameters],
self._set_editor_symbols(self.ports, self.parameters)
def _set_editor_symbols(
self,
ports: list[Port],
parameters: list[Parameter],
) -> None:
self._apply_editor_symbols(
[
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],
)
def _new_combo(self, values: list[tuple[str, str]], current: str) -> QComboBox:
combo = QComboBox(self)
for label, value in values:
combo.addItem(label, value)
combo.setCurrentIndex(max(0, combo.findData(current)))
combo.currentIndexChanged.connect(self._symbols_modified)
return combo
def _append_port(self, port: Port, orientation: str) -> None:
table = self.ui.portsTable
row = table.rowCount()
table.insertRow(row)
name = QTableWidgetItem(port.name)
name.setData(ID_ROLE, port.id)
name.setData(PROPERTIES_ROLE, deepcopy(port.properties))
table.setItem(row, 0, name)
types = [(item.display_name, item.id) for item in PortTypeRegistry.all()]
table.setCellWidget(row, 1, self._new_combo(types, port.type))
table.setCellWidget(
row,
2,
self._new_combo([("Input", "input"), ("Output", "output")], orientation),
)
multiple = QCheckBox("Any", self)
multiple.setChecked(port.allows_multiple_connections)
multiple.toggled.connect(self._symbols_modified)
table.setCellWidget(row, 3, multiple)
def _append_parameter(self, parameter: Parameter) -> None:
table = self.ui.parametersTable
row = table.rowCount()
table.insertRow(row)
name = QTableWidgetItem(parameter.name)
name.setData(ID_ROLE, parameter.id)
table.setItem(row, 0, name)
table.setItem(row, 1, QTableWidgetItem(parameter.type))
table.setItem(row, 2, QTableWidgetItem(parameter.value))
def add_port(self) -> None:
port = Port(
id=f"port-{uuid4().hex[:8]}",
name=f"Port {self.ui.portsTable.rowCount() + 1}",
type="signal",
properties={"iconPosition": {"x": 0.0, "y": 0.0}},
)
self._loading = True
self._append_port(port, "input")
self._loading = False
self.ui.portsTable.selectRow(self.ui.portsTable.rowCount() - 1)
self._symbols_modified()
def remove_port(self) -> None:
row = self.ui.portsTable.currentRow()
if row >= 0:
self.ui.portsTable.removeRow(row)
self._symbols_modified()
self._update_buttons()
def add_parameter(self) -> None:
parameter = Parameter(
id=f"parameter-{uuid4().hex[:8]}",
name=f"Parameter {self.ui.parametersTable.rowCount() + 1}",
)
self._loading = True
self._append_parameter(parameter)
self._loading = False
self.ui.parametersTable.selectRow(self.ui.parametersTable.rowCount() - 1)
self._symbols_modified()
def remove_parameter(self) -> None:
row = self.ui.parametersTable.currentRow()
if row >= 0:
self.ui.parametersTable.removeRow(row)
self._symbols_modified()
self._update_buttons()
def _update_buttons(self) -> None:
self.ui.removePortButton.setEnabled(self.ui.portsTable.currentRow() >= 0)
self.ui.removeParameterButton.setEnabled(
self.ui.parametersTable.currentRow() >= 0
)
def _apply_editor_symbols(
self, inputs: list[str], outputs: list[str], parameters: list[str]
) -> None:
symbols = (inputs, outputs, parameters)
self.ui.declarationsEdit.set_symbols(*symbols)
self.ui.initialEquationsEdit.set_symbols(*symbols)
self.ui.equationsEdit.set_symbols(*symbols)

View File

@@ -862,6 +862,75 @@ N\xa7\xd3>==\xfd\xa1\xc3\xe1\x98\x92$\xe9I\x00\
\xbf\xc9\xd3\x01\x9f\xa3\x0f\x17Z/\xe3\x7f\xe1/\x17\x85\
\xd6\x06q(\x0e\x10\x00\x00\x00\x00IEND\xaeB\
`\x82\
\x00\x00\x04)\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\
\x00\x00\x00\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\
\xa7\x93\x00\x00\x00\x09pHYs\x00\x007\x5c\x00\x00\
7\x5c\x01\xcb\xc7\xa4\xb9\x00\x00\x03\x9bIDATx\
\xda\xedT=o\x1bG\x10}\xbb\xb7w$\xf5A\x9e\
lJEB\x18\xb1\x0a\x1bv\x13\xdb\x85\xaa\x18P$\
\xa4\x89\x81\x94\xd6_\x08`\xb8r\x93 \x85\x02\x01\xa9\
\x9c\x1f\xe0>\x85\x81\x94F\x00\xa7\x11\xd88\x85\x1bK\
6@)\x94-\xc8\x96\x10\x18\x81\x22\xf3C\x22i\xf2\
\xf6v\xf3B.s\xd1\xb1H\x80\xb83\x07x\x98\xbd\
\xdd\x99y3\xb3\xb7\x83\xb1\x8ce,\xef\xbd\x88\x1f\x97\
\x97\xe1\x05\x01\x820D~~\x1e\xdb\xf7\xee\xa1st\
\x94\x188H\x07/\x05\xe5\xe0\x0f\xb5C\xf0\xb7N\xd6\
\xb9\xb3g\x11\xde\xba\x85\xf8\xd5+\xc4\xb5\x1a`\x0c\xc4\
\x83B\x01\x82\xe4\xe1\xcd\x9b\xf2\x8f'Ol.\x9f\xb7\
\x96\x07\xd1\xc9\x09\xbeX_\xc7\xbb\x90\xd7\xd7\xafCN\
M\x01RB7\x9bbraA4\xee\xdf7q\xa3\
\x01\xf1\xc3\xf9\xf3\xf0\xc3P\xac\x90\xfc\xd1\xda\x9a|v\
\xf7\xee4\xc9\xbdw\xda\x01\x87\xec\xf4t<\xb7\xba\xda\
\xcc\xdc\xb9c\xebW\xae\x88\xb8^\xb7\xa2|\xfb\xb6\xf8\
\xe0\xeaU\xdbl4>?\xda\xd8\xf8\xf6\xa0\x5c\xfe\xa8\
yp \xff=\x01G\x9eN\x22!\x1fM\xa0T2\
S\x8b\x8b{\xb9\x85\x85U\xe4r?G\x9b\x9bB\xd5\
\xaaU[\x7f\xfe|)\xbcp\xe1Avv\xd6\x93a\
h\xdb\x07\x07B\xfc\xe7\x04\x122\xe3`\x07p~\x09\
\x04c\xa3X\x9c\xd5;;?\x89\xdd\xdd\xcf\xa41e\
e\x94B\xb3Z\xfd\xca\xfa\xbeWZZ\x8a>^Y\
Q\xf3\xec\x88\xa7\x94#'\xac\x85L\x05\x1c\x09\x9e\xe8\
S~\x88cxR\x22\xc8\xe7\xa1\xe7\xe6`\x84\x88\xf4\
\xe3\xc7\xbe\xa8V\xbf6\x17/\x96\xd5\xeb\x87\x0f\x0bZ\
\xa9K\xad(\xc2\x87\xd7\xaey\xc1\x8b\x17b\xe6\xf8\x18\
\xca\xf3\xe0Y\x9bT<B\x98\x00B\x0c\xaa\xa6\xbd!\
bc\xfa\xdad2\x00\x89\xdbL`\xfb\xe9S\xfc\xba\
\xb5\x85Oo\xdc\xf0J\x95\x0a\xec\xfe\xfe\xa5\xdc\xcb\x97\
\x05\x15[\xabzQ\xe4\xd7\xf6\xf7qD\x833$\xb7\
LF1\xa8JZ\x9dJ\xc2\x81$\x18VJX\x12\
\x81\xa4rr\x12&\x9bE\xa7\xd3\xc1\xef\x9b\x9b\xf8m\
{\x1b\xf5\xb7o\xd1e\xcc\xe3j\x15zo\x0f\xf4\xf1\
\xd9\x0d\xa5\x0c\x00\x87~\xe6\xacf\x00\x0a\x83\x9e\xd6\xff\
l=\xc9$\xbb$\x95\x02\x82\x00\xa0\x8e\xe9\xd7k\xb7\
\xd1\x22A\x83o\xbd~x\x88\xb6\xf3\xcb\xf2\xacEm\
\xc8A\xe2~\xd2\xc6\x15\xa7\xb9\x88b\x00o8\x1c\xba\
t\x0a\xba]L\xb1\x82\xac\xef#\xabT\x9f\xcc#\x04\
\x1d\xe5\x10\x0c \xb4\x86\xe9\xf5`834\xdft\xaf\
^G\xf7\xafJ\x1di\x86v\x9a~]\xdaj\x12c\
b\x02\xbe\xbb\x22IN^\x99V\x87B4&\xac\xad\
\x00(\xedll\xe8\xe2\xb9s\xbeGbEC\x9fW\
\xe1\xc71|!\x86W\xd2\xdfW\x0c\xe0q_\x11\xc3\
\xffD\xce\xcc@\x16\x8b\x10\x1e\xbf\x5c\x85Y\x22\x00P\
\xa0\xd6\xd4>\x8b9\xb3\xbb\xab\xa3A\xcf*5r\x8b\
5\xf4\xe5\x93\x0e\xb0~\xc2\x03\x1eZ#%\xfd\x93\xb6\
\xb3\xda\xd3O\x8f\x04\xbe\xd3\xa7\xde<\xed2\xc0\x08\xb2\
\x0e\x19\x06\xa5\xbf\xe0\xbaG\xbfe\x00\x8f\xd4174\
\x17\x0c\xb0\x04\xe0;\x03\x5cf\x8b\xa4M?\xad\xd4\xcf\
G\x0dV\x9f\x9e\x0f\xce~ v\xf8o\x11zpn\
\x14\xb0\xc5\xbdo\xda\xc0/\x92\xa6j\xd2\xcd\x8c<7\
\x9e\x01\x8b4\xca\xd3X%\x09$\xc3%\x91\xd1\xa4b\
\x87\xd40J\xdbk\xee5\xc9\x09=\xd8\xb2\xea\x8dK\
\xb6\x05\xc8\x90Z\xd0\x00\xceY\x11\xdf\xe3\xffI%\xf5\
mI\xdc\x224`\xfaE~\xe9\x0e\xa4\xd3=B\xa7\
F\xe9\xe8\xe8\x1dE\x90B&\xd1)\x0c\xc4`,c\
\x19\xcbX\x06\xf2'\xe3\xdf\x9d\x06\x06\xf6\x92\x0e\x00\x00\
\x00\x22zTXtSoftware\x00\x00\
x\xda+//\xd7\xcb\xcc\xcb.NN,H\xd5\xcb\
/J\x07\x006\xd8\x06X\x10S\xca\x5c\x00\x00\x00\x00\
IEND\xaeB`\x82\
\x00\x00\x02V\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -2383,6 +2452,101 @@ u1\xb8\xdd0`x\xe8F\xf9\x1e\xe19\xea\x8a\x03\
|f\x1e\x89\xd1\x9ef\xbdhx\x0fE4mL\xaf\
b/B\x06\x12o\xbf\x00\xa3\x17WYZq\xd9W\
\x00\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x05\xcf\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
\x00\x00 \x00\x00\x00 \x08\x06\x00\x00\x00szz\xf4\
\x00\x00\x00\x04sBIT\x08\x08\x08\x08|\x08d\x88\
\x00\x00\x00\x09pHYs\x00\x00\x03v\x00\x00\x03v\
\x01}\xd5\x82\xcc\x00\x00\x00\x19tEXtSof\
tware\x00www.inksca\
pe.org\x9b\xee<\x1a\x00\x00\x05LID\
ATx\xda\xc5\x97\xdboTE\x1c\xc7\xbf3s\xce\
\x9e\xeeni\x0b\xbd@\x01C1\x05B\x10\x0d\xf4\x22\
\x18\x95D0i\xc4\x17|21\xc6\x17\xc3\x93\x89\xd1\
\xc8\x8b\xfaP#oj\x8c\x89O\xfc\x03\xc6K\x0c\xf1\
\x01!B\xa8\xc1j\x88D\x90@)\x14\xac\xb5\x86K\
K\xaf\xdb\xdd={\xce\xcc\xef\xe7\xec\x9cficb\
\xd2\x98\x13f\xf3\xdd93\xbf\x93\x9d\xcf\xfengW\
03\x1e\xe6\xf0\xb0\xc2\xf1\xca\xe9\x17wok\xda~\
lh\xe6\xe2\xd6\xbf\x0a#\xab\x94\x92h\xcb\xad\x9d{\
z\xdd\xfe\x9b\xf9 \xf7\xce\xe1\xad\xef\x0d`\x05Cb\
\x05\xa3\xe7\xcb\xce\xc7\x0emz\xf9\xfc\xab\x9d\x87\xbb\xf6\
\xb4\xec[\x15k\x82\x8e\x09\x9d\xf9\xed\x8d\x07\xda\x0fu\
=\xd5\xd2w\xe6\x83K\x87{S\xf3@N\xe4>\x9d\
)M{>\xd5\xa1\xab\xad\x17\x1b\x1a\xda\xdd~V\xe6\
\xb1)\xb7\x05\xe7\xee\x9e\x94\xf3\xa5\xe2'\x00\x9eI\x05\
\xe0^a\xa2\xe7\xdc\xdf\x03\xe8\x5c\xb3\x05\xf3\xb9k\x10\
A\xc5\xed\x87\x98\xc4\x99\xdb\xdf\xe1\xd4\xd8\xb7\x18-\x0e\
\xefL\xcd\x03aTi\x1c\x1c\xff\x11\xdd\x1bw\xa3I\
\x99\xe5F3\x87+\xf7/\xc2W\xb215\x00\x133\
\x08\x11E(\xc7\xcb\x01<\xa3\x9d]2#5\x00\
\xd2\x04!$\x8c!\x94u\xb4\xccVG1\x8c6\x10\
@\x9a\x00\x0c\x08\x86!B\x18G\x80X\xea\x01\x03c\
\xed\x02\x94\x1e\x80Y\x04\xd0d`,\x80\x00j\x10\x01\
i\x18\xc3\x10\xd6\x9ej\x0e\x08\xc5 bD:^\xe6\
\x01M\xfa\xffy\xe0\xe8\xe0\xd1\x17\xaeN\x5c\xed\x1f\x18\
\x1d\xd869w\xafA\x92\x84d\xc0\xbeC\xb1\x80\x94\
\x02JHw81!2\x0e\xa0\xc6\xa0\x8dv9b\
\xd0ul\x03{\xbe\x84\xaf\x14<_\xc1\xf7\xecu\
F\xa15\xd76\xff\xc4\xda\x9e\xeb\x1d\xab\xb6\xf7\xbf\xb4\
\xf9\xf5\x13\xb5N\xd8\xf7E_\xefTi\xea\xf8\xc6\xfa\
\x8d=mA[\x03\x99$\xdel\xd8\xcd\xb48\x1bw\
M\xd0\xdaX\x00\x8d\xd8\x98%\x8a@D\xee^c\xc8\
\xddk(\xb9\xdfp\xb2W\xef564\xaa\xd6\x9e\xf9\
h\xea\xf8G\xc3o\xb8\x8e\xe9\x1eF\x1d\x9fw\xfc\xb0\
\xabm\xd7\x81\xbd\xeb\xf7\x02\xc2`.\x9aAu\x9f\x85\
\x15\x5c\xdc\x935\x0c\x84\x00r\xb9,\xee\x9a?!\x93\
$p{\x1b\xbcGQ\xa9\x84P\xd2\x83'\xfd\x9a\x94\
\xf2\x90\xb1{\x81\xca\xc2g\xcf\xda\x0d\xc6\xc2!\xdc\x89\
n\x9e\xf9\xec\xc9\xef\x0f\xb8\x10\x8c\xcf\x8dw#\x06z\
\xda\xbb0T\xb8\x00-4 Q\x93\x84\x80\x14\x02J\
J7\xcb(Y\x0b'\xc7\x87\xb1\xe8\x06X\x00L\x9c\
x\x81\xaa\x22\x042\x8b\xbcX\x8d\x91\x89Q\x9c\x1a:\
\x8d\xf7\xf7\xbd\x85\xdb4\x8cyLv\xd5r\x80\x88\x9a\
\xc6\xa6\xc602u\x1dEQN\x0eVp\xb3\x10H\
\x0e\x96\x02\xcc\xb2\x06\xc2\xe2\x01Dm0\x83\x00x\xc2\
\xc3\xba\xecz\xcc\x95\x8b\xb81}\x0bgG\xbfF\x1c\
\x11\x00\xd8\xf50\x8a\xc1md|\xd9T\x03\x00[\x91\
k\xb5\xa8\xa88\x01 \x07\x91$\x1fK+\x07\x90\x1c\
\x0e\x86\xaa\x02A8\xefdT\x1d\x9a\x83V\x17\x8e0\
\x0a1\xb10\x81o\xfe\xf8\x0a\x85J\x11\xa5(\xb6\xa0\
\x12\x90\xe42V\xb3v\xf9@\x9e\xc0R\x80Y\x10\x9a\
f\x0a\xb3\xb8^\xbc\x09?\xf0\x9c\x82L\x80l\x10\xc0\
>\xe7\x91\xf7\xb2\x8eI2\x01,]G\x0cD\x00\x22\
\x8d\xf9h\x1a\xc3\x93\xbf!\xd4\x15h\x22D\xc6\x00\xd2\
\xc0S\xca\xca@\x19W.N\x19\xcf\xc3\x02\xbb\xf0\xcc\
.-\xc3A\x10\x0e\x0e\x0e\xff\x82\x1d\xebw .\xc5\
@9\x09A$\x04H\x85(\x8b\x18\x9e'\xdc\x87\xd6\
5H\xdc\xc1-\x04\x9erkea\x1e\xf1;Q\xd1\
\x06\xbc\x987\xe43L\x96\xa09)[cU\xefe\
1e\x93\x97du\x8f\x7f~\x00\xa0q\x04\xc0\xfe\x85\
\xf2B\xdd\xf9k\xe7\x81x1\x04\x9cP\x0b+)\x92\
^ \x94\xc0\xb3{z\xe1\xafV\xc8T3\xbc\x0a!\
%\xc2B\x8c\x9f.\xffj!%\xaa=@YyK\
\xe4\xfb\x0a\x85\x8cB&c\xd7\x19\xaf\xacY\x1f\xa9\xf5\
\x01\xfe\x90\xaf\x81\xd0\x03\xe0,\x08\x0b0@M\x1a`\
;SU\x94\x5ck\x13\xbbC}%\x1d@\xe0{\xae\
D\x99\x01\x02\xbb\xba\xa7\x7f\x89\xacx\xce\xdaN\xda\xb9\
\xfbD\xdf\xc8\xd0\xb2N\xc8\x1f\xf3\x15\x00\xcf\xfdg\xdb\
|M2\x0b\xd7\xf5\x9cG\xbc*\x80\xb2\x00\x9e\x87J\
r\x00$\x0b\x8c\xbd;#R\xf9MH&\xa9\xf3\x98\
b\xf0b|\x94L\xbc\x00h\x07@)\xfe\x1ep\x87\
3\x0bT\xe2\x18\xc2\x18\x18J\xbe\xb5X\xecF\xc9+\
E\x80\x04\x02(\xeb\x0a8\xd2P2\xb2J\x1aR1\
\xa2\xa4\x9d\xa4\x0e \x18\xa5(D\x14F\xae\xe6+\xb1\
F!\xac ,\xf9 k\xb3<)\x02\x88D\x0bq\
\x08*VP\xac\xc4\xae\x17\xb8\xf2,f\xc0\x92\xacd\
z\x00^F\x15\x1b\xb2A\xbeE\x05\xb8Q\x9aK\xda\
\xb4U\xf5\xb5)\xee@=1B.\x15R\xfbg\x94\
\xad\xf7\x7f\xdf\xd9\xde\x82\x83\xcdk\xb03n\x86\x9cR\
\xc0\xb4D\xb7\xea\xc5V\xde\x82<\xea!H^H\x0d\
\xdf\x1c\xbc\xb9ym\xa0W7*\xb4\xe6\xf3 c\
\xc0Dx\xa2\xf9q\xc8@Add$`\xdeN\x0d\
\xe0N\xff\xec\x05\xd9\x1c=?\xee\xf3\xe5\xfb\x22Z\xa0\
,\x83\xeb\x18*\xab\xc2 \x9b\xb9\x12\xa0n\xffD\x7f\
\xf1\x12V0\x1e\xfa\xdf\xf3\x7f\x00j\xf0\xda\xe8\xbc\xba\
\xd0\x0a\x00\x00\x00\x00IEND\xaeB`\x82\
\x00\x00\x04<\
\x89\
PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
@@ -2649,6 +2813,10 @@ qt_resource_name = b"\
\x0f\xba\x1e'\
\x00d\
\x00r\x00a\x00w\x00-\x00p\x00a\x00t\x00h\x00.\x00p\x00n\x00g\
\x00\x0f\
\x020\x8b\xe7\
\x00l\
\x00i\x00s\x00t\x00-\x00r\x00e\x00m\x00o\x00v\x00e\x00.\x00p\x00n\x00g\
\x00\x0d\
\x0b\xe6\x1f\xa7\
\x00d\
@@ -2723,6 +2891,10 @@ qt_resource_name = b"\
\x00o\
\x00f\x00f\x00i\x00c\x00e\x00-\x00c\x00h\x00a\x00r\x00t\x00-\x00l\x00i\x00n\x00e\
\x00.\x00p\x00n\x00g\
\x00\x0c\
\x09\xc6\x19'\
\x00l\
\x00i\x00s\x00t\x00-\x00a\x00d\x00d\x00.\x00p\x00n\x00g\
\x00\x10\
\x03\xe6\xd3g\
\x00d\
@@ -2749,67 +2921,71 @@ qt_resource_struct = b"\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x1e\x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x02\x00\x00\x00 \x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x01\xfe\x00\x00\x00\x00\x00\x01\x00\x00N\xe8\
\x00\x00\x02\x22\x00\x00\x00\x00\x00\x01\x00\x00S\x15\
\x00\x00\x01\x9f{C\xf1'\
\x00\x00\x02>\x00\x00\x00\x00\x00\x01\x00\x00Z\x1a\
\x00\x00\x02b\x00\x00\x00\x00\x00\x01\x00\x00^G\
\x00\x00\x01\x9f\x7f\xa8\xa8)\
\x00\x00\x02\xec\x00\x00\x00\x00\x00\x01\x00\x00tV\
\x00\x00\x03\x10\x00\x00\x00\x00\x00\x01\x00\x00x\x83\
\x00\x00\x01\x9f{0\xc99\
\x00\x00\x03\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x8d\xaa\
\x00\x00\x01d\x00\x00\x00\x00\x00\x01\x00\x004B\
\x00\x00\x01\x9f\x84\xbd\x03\xb8\
\x00\x00\x03\xc8\x00\x00\x00\x00\x00\x01\x00\x00\x91\xd7\
\x00\x00\x01\x9f\x84\x8f\x00\xb9\
\x00\x00\x01\xc4\x00\x00\x00\x00\x00\x01\x00\x00D2\
\x00\x00\x01\xe8\x00\x00\x00\x00\x00\x01\x00\x00H_\
\x00\x00\x01\x9f\x7f&\x83\xcd\
\x00\x00\x01\xa4\x00\x00\x00\x00\x00\x01\x00\x00<J\
\x00\x00\x01\xc8\x00\x00\x00\x00\x00\x01\x00\x00@w\
\x00\x00\x01\x9f{C\xf1\x18\
\x00\x00\x03\xd4\x00\x00\x00\x00\x00\x01\x00\x00\x91\x1b\
\x00\x00\x04\x16\x00\x00\x00\x00\x00\x01\x00\x00\x9b\x1b\
\x00\x00\x01\x9f\x7fY\xceg\
\x00\x00\x02\xa2\x00\x00\x00\x00\x00\x01\x00\x00f\xc8\
\x00\x00\x02\xc6\x00\x00\x00\x00\x00\x01\x00\x00j\xf5\
\x00\x00\x01\x9f\x7fV\xd5\xc0\
\x00\x00\x03Z\x00\x00\x00\x00\x00\x01\x00\x00\x84\xc5\
\x00\x00\x03~\x00\x00\x00\x00\x00\x01\x00\x00\x88\xf2\
\x00\x00\x01\x9f\x7f&\x83r\
\x00\x00\x02\xce\x00\x00\x00\x00\x00\x01\x00\x00m[\
\x00\x00\x02\xf2\x00\x00\x00\x00\x00\x01\x00\x00q\x88\
\x00\x00\x01\x9f\x7f&\x83\xb1\
\x00\x00\x01\x1c\x00\x00\x00\x00\x00\x01\x00\x00,\x08\
\x00\x00\x01\x9f\x7fY\xce\x82\
\x00\x00\x01\xe0\x00\x00\x00\x00\x00\x01\x00\x00Kh\
\x00\x00\x02\x04\x00\x00\x00\x00\x00\x01\x00\x00O\x95\
\x00\x00\x01\x9f{C\xf1.\
\x00\x00\x03\xfa\x00\x00\x00\x00\x00\x01\x00\x00\x95[\
\x00\x00\x04<\x00\x00\x00\x00\x00\x01\x00\x00\x9f[\
\x00\x00\x01\x9f\x84\x8f\xa6\x10\
\x00\x00\x01\x84\x00\x00\x00\x00\x00\x01\x00\x006\x9c\
\x00\x00\x01\xa8\x00\x00\x00\x00\x00\x01\x00\x00:\xc9\
\x00\x00\x01\x9f{{\xa5\xd5\
\x00\x00\x04&\x00\x00\x00\x00\x00\x01\x00\x00\x97\x0c\
\x00\x00\x04h\x00\x00\x00\x00\x00\x01\x00\x00\xa1\x0c\
\x00\x00\x01\x9f\x7f&\x83\x10\
\x00\x00\x03\xf8\x00\x00\x00\x00\x00\x01\x00\x00\x95H\
\x00\x00\x01\x9f\x84\xbd\x03\xde\
\x00\x00\x00\xfe\x00\x00\x00\x00\x00\x01\x00\x00(e\
\x00\x00\x01\x9f\x7f&\x83~\
\x00\x00\x036\x00\x00\x00\x00\x00\x01\x00\x00\x80\xe2\
\x00\x00\x03Z\x00\x00\x00\x00\x00\x01\x00\x00\x85\x0f\
\x00\x00\x01\x9f\x7f&\x83T\
\x00\x00\x00~\x00\x00\x00\x00\x00\x01\x00\x00\x14M\
\x00\x00\x01\x9f\x7fY\xce^\
\x00\x00\x00\xa2\x00\x00\x00\x00\x00\x01\x00\x00\x19Y\
\x00\x00\x01\x9f{0\xc9B\
\x00\x00\x01d\x00\x00\x00\x00\x00\x01\x00\x004B\
\x00\x00\x01\x88\x00\x00\x00\x00\x00\x01\x00\x008o\
\x00\x00\x01\x9f\x7f&\x83)\
\x00\x00\x03\x14\x00\x00\x00\x00\x00\x01\x00\x00{`\
\x00\x00\x038\x00\x00\x00\x00\x00\x01\x00\x00\x7f\x8d\
\x00\x00\x01\x9f{C\xf1N\
\x00\x00\x02p\x00\x00\x00\x00\x00\x01\x00\x00b\x99\
\x00\x00\x02\x94\x00\x00\x00\x00\x00\x01\x00\x00f\xc6\
\x00\x00\x01\x9f\x7f&\x82\xf2\
\x00\x00\x00\xd0\x00\x00\x00\x00\x00\x01\x00\x00!\xc5\
\x00\x00\x01\x9f{\x8d\xf34\
\x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x9f{0\xc9+\
\x00\x00\x02\x1e\x00\x00\x00\x00\x00\x01\x00\x00RH\
\x00\x00\x02B\x00\x00\x00\x00\x00\x01\x00\x00Vu\
\x00\x00\x01\x9f{C\xf1=\
\x00\x00\x006\x00\x00\x00\x00\x00\x01\x00\x00\x05\x86\
\x00\x00\x01\x9f\x7f&\x83\x99\
\x00\x00\x04P\x00\x00\x00\x00\x00\x01\x00\x00\x99\xa2\
\x00\x00\x04\x92\x00\x00\x00\x00\x00\x01\x00\x00\xa3\xa2\
\x00\x00\x01\x9f{{\xa5\xe3\
\x00\x00\x00^\x00\x00\x00\x00\x00\x01\x00\x00\x0c\xc1\
\x00\x00\x01\x9f\x7f\xac\xf2\xc6\
\x00\x00\x01D\x00\x00\x00\x00\x00\x01\x00\x00/{\
\x00\x00\x01\x9f\x7fV\xd5\xe5\
\x00\x00\x03|\x00\x00\x00\x00\x00\x01\x00\x00\x88\xb7\
\x00\x00\x03\xa0\x00\x00\x00\x00\x00\x01\x00\x00\x8c\xe4\
\x00\x00\x01\x9f{0\xc9R\
"

View File

@@ -56,50 +56,53 @@ class Ui_MainWindow(object):
icon4 = QIcon()
icon4.addFile(u":/icons/icons/office-chart-line.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSimulationWindow.setIcon(icon4)
self.actionExportModel = QAction(MainWindow)
self.actionExportModel.setObjectName(u"actionExportModel")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionExportModel.setIcon(icon5)
self.actionNew = QAction(MainWindow)
self.actionNew.setObjectName(u"actionNew")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionNew.setIcon(icon5)
icon6 = QIcon()
icon6.addFile(u":/icons/icons/document-new.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionNew.setIcon(icon6)
self.actionRotateClockwise = QAction(MainWindow)
self.actionRotateClockwise.setObjectName(u"actionRotateClockwise")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/transform-rotate.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRotateClockwise.setIcon(icon6)
icon7 = QIcon()
icon7.addFile(u":/icons/icons/transform-rotate.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRotateClockwise.setIcon(icon7)
self.actionZoomIn = QAction(MainWindow)
self.actionZoomIn.setObjectName(u"actionZoomIn")
icon7 = QIcon()
icon7.addFile(u":/icons/icons/zoom-in.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomIn.setIcon(icon7)
icon8 = QIcon()
icon8.addFile(u":/icons/icons/zoom-in.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomIn.setIcon(icon8)
self.actionZoomOut = QAction(MainWindow)
self.actionZoomOut.setObjectName(u"actionZoomOut")
icon8 = QIcon()
icon8.addFile(u":/icons/icons/zoom-out.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomOut.setIcon(icon8)
icon9 = QIcon()
icon9.addFile(u":/icons/icons/zoom-out.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionZoomOut.setIcon(icon9)
self.actionCenterView = QAction(MainWindow)
self.actionCenterView.setObjectName(u"actionCenterView")
icon9 = QIcon()
icon9.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCenterView.setIcon(icon9)
icon10 = QIcon()
icon10.addFile(u":/icons/icons/zoom-original.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionCenterView.setIcon(icon10)
self.actionOpen = QAction(MainWindow)
self.actionOpen.setObjectName(u"actionOpen")
icon10 = QIcon()
icon10.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon10)
icon11 = QIcon()
icon11.addFile(u":/icons/icons/document-open.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionOpen.setIcon(icon11)
self.actionReloadLibraries = QAction(MainWindow)
self.actionReloadLibraries.setObjectName(u"actionReloadLibraries")
self.actionReloadSimulation = QAction(MainWindow)
self.actionReloadSimulation.setObjectName(u"actionReloadSimulation")
self.actionSave = QAction(MainWindow)
self.actionSave.setObjectName(u"actionSave")
icon11 = QIcon()
icon11.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon11)
icon12 = QIcon()
icon12.addFile(u":/icons/icons/document-save.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSave.setIcon(icon12)
self.actionSaveAs = QAction(MainWindow)
self.actionSaveAs.setObjectName(u"actionSaveAs")
icon12 = QIcon()
icon12.addFile(u":/icons/icons/document-save-as.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionSaveAs.setIcon(icon12)
self.actionSaveAs.setIcon(icon5)
self.actionExit = QAction(MainWindow)
self.actionExit.setObjectName(u"actionExit")
self.actionClose = QAction(MainWindow)
@@ -295,7 +298,7 @@ class Ui_MainWindow(object):
self.rotateToolButton = QToolButton(self.workspaceHeader)
self.rotateToolButton.setObjectName(u"rotateToolButton")
self.rotateToolButton.setIcon(icon6)
self.rotateToolButton.setIcon(icon7)
self.workspaceHeaderLayout.addWidget(self.rotateToolButton)
@@ -441,6 +444,7 @@ class Ui_MainWindow(object):
self.menuSimulation.addAction(self.actionSimulationSettings)
self.menuSimulation.addAction(self.actionGraphParameters)
self.menuSimulation.addAction(self.actionCompose)
self.menuSimulation.addAction(self.actionExportModel)
self.menuSimulation.addAction(self.actionSimulationWindow)
self.menuSimulation.addAction(self.actionRunSimulation)
self.fileToolbar.addAction(self.actionNew)
@@ -496,6 +500,10 @@ class Ui_MainWindow(object):
self.actionSimulationWindow.setText(QCoreApplication.translate("MainWindow", u"Simulation Window", None))
#if QT_CONFIG(statustip)
self.actionSimulationWindow.setStatusTip(QCoreApplication.translate("MainWindow", u"Show the simulation results window", None))
#endif // QT_CONFIG(statustip)
self.actionExportModel.setText(QCoreApplication.translate("MainWindow", u"Export Model\u2026", None))
#if QT_CONFIG(statustip)
self.actionExportModel.setStatusTip(QCoreApplication.translate("MainWindow", u"Save the composed OpenModelica model to a file", None))
#endif // QT_CONFIG(statustip)
self.actionNew.setText(QCoreApplication.translate("MainWindow", u"&New", None))
#if QT_CONFIG(statustip)

View File

@@ -15,16 +15,17 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QDialog, QDialogButtonBox,
QFormLayout, QHBoxLayout, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QSplitter, QVBoxLayout, QWidget)
from PySide6.QtWidgets import (QAbstractButton, QApplication, QComboBox, QDialog,
QDialogButtonBox, QFormLayout, QHBoxLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit,
QPushButton, QSizePolicy, QSpinBox, QSplitter,
QVBoxLayout, QWidget)
class Ui_ParameterOptionsDialog(object):
def setupUi(self, ParameterOptionsDialog):
if not ParameterOptionsDialog.objectName():
ParameterOptionsDialog.setObjectName(u"ParameterOptionsDialog")
ParameterOptionsDialog.resize(620, 380)
ParameterOptionsDialog.resize(720, 500)
self.dialogLayout = QVBoxLayout(ParameterOptionsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.parameterSplitter = QSplitter(ParameterOptionsDialog)
@@ -76,20 +77,82 @@ class Ui_ParameterOptionsDialog(object):
self.parameterDetailsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.typeLabel)
self.typeEdit = QLineEdit(self.parameterDetailsPanel)
self.typeEdit.setObjectName(u"typeEdit")
self.typeCombo = QComboBox(self.parameterDetailsPanel)
self.typeCombo.addItem("")
self.typeCombo.addItem("")
self.typeCombo.addItem("")
self.typeCombo.addItem("")
self.typeCombo.setObjectName(u"typeCombo")
self.typeCombo.setEditable(True)
self.parameterDetailsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.typeEdit)
self.parameterDetailsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.typeCombo)
self.rowsLabel = QLabel(self.parameterDetailsPanel)
self.rowsLabel.setObjectName(u"rowsLabel")
self.parameterDetailsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.rowsLabel)
self.rowsSpin = QSpinBox(self.parameterDetailsPanel)
self.rowsSpin.setObjectName(u"rowsSpin")
self.rowsSpin.setMinimum(1)
self.rowsSpin.setMaximum(9999)
self.parameterDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.rowsSpin)
self.columnsLabel = QLabel(self.parameterDetailsPanel)
self.columnsLabel.setObjectName(u"columnsLabel")
self.parameterDetailsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.columnsLabel)
self.columnsSpin = QSpinBox(self.parameterDetailsPanel)
self.columnsSpin.setObjectName(u"columnsSpin")
self.columnsSpin.setMinimum(1)
self.columnsSpin.setMaximum(9999)
self.parameterDetailsForm.setWidget(3, QFormLayout.ItemRole.FieldRole, self.columnsSpin)
self.valueLabel = QLabel(self.parameterDetailsPanel)
self.valueLabel.setObjectName(u"valueLabel")
self.parameterDetailsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.valueLabel)
self.parameterDetailsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.valueLabel)
self.valueEdit = QLineEdit(self.parameterDetailsPanel)
self.valueEdit.setObjectName(u"valueEdit")
self.parameterDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.valueEdit)
self.parameterDetailsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.valueEdit)
self.quantityLabel = QLabel(self.parameterDetailsPanel)
self.quantityLabel.setObjectName(u"quantityLabel")
self.parameterDetailsForm.setWidget(5, QFormLayout.ItemRole.LabelRole, self.quantityLabel)
self.quantityCombo = QComboBox(self.parameterDetailsPanel)
self.quantityCombo.setObjectName(u"quantityCombo")
self.quantityCombo.setEditable(True)
self.parameterDetailsForm.setWidget(5, QFormLayout.ItemRole.FieldRole, self.quantityCombo)
self.unitLabel = QLabel(self.parameterDetailsPanel)
self.unitLabel.setObjectName(u"unitLabel")
self.parameterDetailsForm.setWidget(6, QFormLayout.ItemRole.LabelRole, self.unitLabel)
self.unitCombo = QComboBox(self.parameterDetailsPanel)
self.unitCombo.setObjectName(u"unitCombo")
self.unitCombo.setEditable(True)
self.parameterDetailsForm.setWidget(6, QFormLayout.ItemRole.FieldRole, self.unitCombo)
self.descriptionLabel = QLabel(self.parameterDetailsPanel)
self.descriptionLabel.setObjectName(u"descriptionLabel")
self.parameterDetailsForm.setWidget(7, QFormLayout.ItemRole.LabelRole, self.descriptionLabel)
self.descriptionEdit = QPlainTextEdit(self.parameterDetailsPanel)
self.descriptionEdit.setObjectName(u"descriptionEdit")
self.descriptionEdit.setMaximumHeight(100)
self.parameterDetailsForm.setWidget(7, QFormLayout.ItemRole.FieldRole, self.descriptionEdit)
self.parameterSplitter.addWidget(self.parameterDetailsPanel)
@@ -115,6 +178,16 @@ class Ui_ParameterOptionsDialog(object):
self.removeParameterButton.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Remove Parameter", None))
self.nameLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Name:", None))
self.typeLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Type:", None))
self.typeCombo.setItemText(0, QCoreApplication.translate("ParameterOptionsDialog", u"real", None))
self.typeCombo.setItemText(1, QCoreApplication.translate("ParameterOptionsDialog", u"integer", None))
self.typeCombo.setItemText(2, QCoreApplication.translate("ParameterOptionsDialog", u"boolean", None))
self.typeCombo.setItemText(3, QCoreApplication.translate("ParameterOptionsDialog", u"string", None))
self.rowsLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Rows:", None))
self.columnsLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Columns:", None))
self.valueLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Value:", None))
self.quantityLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Quantity:", None))
self.unitLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Unit:", None))
self.descriptionLabel.setText(QCoreApplication.translate("ParameterOptionsDialog", u"Description:", None))
# retranslateUi

View File

@@ -18,14 +18,14 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
from PySide6.QtWidgets import (QAbstractButton, QApplication, QCheckBox, QComboBox,
QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
QLabel, QLineEdit, QListWidget, QListWidgetItem,
QPushButton, QSizePolicy, QSplitter, QVBoxLayout,
QWidget)
QPlainTextEdit, QPushButton, QSizePolicy, QSpinBox,
QSplitter, QStackedWidget, QVBoxLayout, QWidget)
class Ui_PortOptionsDialog(object):
def setupUi(self, PortOptionsDialog):
if not PortOptionsDialog.objectName():
PortOptionsDialog.setObjectName(u"PortOptionsDialog")
PortOptionsDialog.resize(620, 380)
PortOptionsDialog.resize(760, 421)
self.dialogLayout = QVBoxLayout(PortOptionsDialog)
self.dialogLayout.setObjectName(u"dialogLayout")
self.portSplitter = QSplitter(PortOptionsDialog)
@@ -61,6 +61,7 @@ class Ui_PortOptionsDialog(object):
self.portDetailsPanel.setObjectName(u"portDetailsPanel")
self.portDetailsForm = QFormLayout(self.portDetailsPanel)
self.portDetailsForm.setObjectName(u"portDetailsForm")
self.portDetailsForm.setVerticalSpacing(3)
self.portDetailsForm.setContentsMargins(0, 0, 0, 0)
self.nameLabel = QLabel(self.portDetailsPanel)
self.nameLabel.setObjectName(u"nameLabel")
@@ -91,6 +92,7 @@ class Ui_PortOptionsDialog(object):
self.orientationCombo = QComboBox(self.portDetailsPanel)
self.orientationCombo.addItem("")
self.orientationCombo.addItem("")
self.orientationCombo.addItem("")
self.orientationCombo.setObjectName(u"orientationCombo")
self.portDetailsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.orientationCombo)
@@ -104,7 +106,154 @@ class Ui_PortOptionsDialog(object):
self.positionHintLabel.setObjectName(u"positionHintLabel")
self.positionHintLabel.setWordWrap(True)
self.portDetailsForm.setWidget(4, QFormLayout.ItemRole.SpanningRole, self.positionHintLabel)
self.portDetailsForm.setWidget(5, QFormLayout.ItemRole.SpanningRole, self.positionHintLabel)
self.typeOptionsStack = QStackedWidget(self.portDetailsPanel)
self.typeOptionsStack.setObjectName(u"typeOptionsStack")
self.signalOptionsPage = QWidget()
self.signalOptionsPage.setObjectName(u"signalOptionsPage")
self.signalOptionsForm = QFormLayout(self.signalOptionsPage)
self.signalOptionsForm.setObjectName(u"signalOptionsForm")
self.valueTypeLabel = QLabel(self.signalOptionsPage)
self.valueTypeLabel.setObjectName(u"valueTypeLabel")
self.signalOptionsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.valueTypeLabel)
self.valueTypeCombo = QComboBox(self.signalOptionsPage)
self.valueTypeCombo.addItem("")
self.valueTypeCombo.addItem("")
self.valueTypeCombo.addItem("")
self.valueTypeCombo.addItem("")
self.valueTypeCombo.setObjectName(u"valueTypeCombo")
self.valueTypeCombo.setEditable(True)
self.signalOptionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.valueTypeCombo)
self.quantityLabel = QLabel(self.signalOptionsPage)
self.quantityLabel.setObjectName(u"quantityLabel")
self.signalOptionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.quantityLabel)
self.quantityCombo = QComboBox(self.signalOptionsPage)
self.quantityCombo.setObjectName(u"quantityCombo")
self.quantityCombo.setEditable(True)
self.signalOptionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.quantityCombo)
self.unitCombo = QComboBox(self.signalOptionsPage)
self.unitCombo.setObjectName(u"unitCombo")
self.unitCombo.setEditable(True)
self.signalOptionsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.unitCombo)
self.unitLabel = QLabel(self.signalOptionsPage)
self.unitLabel.setObjectName(u"unitLabel")
self.signalOptionsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.unitLabel)
self.rowsColumnsLabel = QLabel(self.signalOptionsPage)
self.rowsColumnsLabel.setObjectName(u"rowsColumnsLabel")
self.signalOptionsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.rowsColumnsLabel)
self.horizontalLayout = QHBoxLayout()
self.horizontalLayout.setObjectName(u"horizontalLayout")
self.rowsSpin = QSpinBox(self.signalOptionsPage)
self.rowsSpin.setObjectName(u"rowsSpin")
self.rowsSpin.setMinimum(1)
self.rowsSpin.setMaximum(9999)
self.horizontalLayout.addWidget(self.rowsSpin)
self.columnsSpin = QSpinBox(self.signalOptionsPage)
self.columnsSpin.setObjectName(u"columnsSpin")
self.columnsSpin.setMinimum(1)
self.columnsSpin.setMaximum(9999)
self.horizontalLayout.addWidget(self.columnsSpin)
self.signalOptionsForm.setLayout(3, QFormLayout.ItemRole.FieldRole, self.horizontalLayout)
self.descriptionLabel = QLabel(self.signalOptionsPage)
self.descriptionLabel.setObjectName(u"descriptionLabel")
self.signalOptionsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.descriptionLabel)
self.descriptionEdit = QPlainTextEdit(self.signalOptionsPage)
self.descriptionEdit.setObjectName(u"descriptionEdit")
self.signalOptionsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.descriptionEdit)
self.typeOptionsStack.addWidget(self.signalOptionsPage)
self.powerOptionsPage = QWidget()
self.powerOptionsPage.setObjectName(u"powerOptionsPage")
self.powerOptionsForm = QFormLayout(self.powerOptionsPage)
self.powerOptionsForm.setObjectName(u"powerOptionsForm")
self.domainLabel = QLabel(self.powerOptionsPage)
self.domainLabel.setObjectName(u"domainLabel")
self.powerOptionsForm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.domainLabel)
self.domainCombo = QComboBox(self.powerOptionsPage)
self.domainCombo.setObjectName(u"domainCombo")
self.powerOptionsForm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.domainCombo)
self.effortLabel = QLabel(self.powerOptionsPage)
self.effortLabel.setObjectName(u"effortLabel")
self.powerOptionsForm.setWidget(1, QFormLayout.ItemRole.LabelRole, self.effortLabel)
self.effortValueLabel = QLabel(self.powerOptionsPage)
self.effortValueLabel.setObjectName(u"effortValueLabel")
self.powerOptionsForm.setWidget(1, QFormLayout.ItemRole.FieldRole, self.effortValueLabel)
self.flowLabel = QLabel(self.powerOptionsPage)
self.flowLabel.setObjectName(u"flowLabel")
self.powerOptionsForm.setWidget(2, QFormLayout.ItemRole.LabelRole, self.flowLabel)
self.flowValueLabel = QLabel(self.powerOptionsPage)
self.flowValueLabel.setObjectName(u"flowValueLabel")
self.powerOptionsForm.setWidget(2, QFormLayout.ItemRole.FieldRole, self.flowValueLabel)
self.causalityLabel = QLabel(self.powerOptionsPage)
self.causalityLabel.setObjectName(u"causalityLabel")
self.powerOptionsForm.setWidget(3, QFormLayout.ItemRole.LabelRole, self.causalityLabel)
self.causalityCombo = QComboBox(self.powerOptionsPage)
self.causalityCombo.setObjectName(u"causalityCombo")
self.powerOptionsForm.setWidget(3, QFormLayout.ItemRole.FieldRole, self.causalityCombo)
self.powerDescriptionLabel = QLabel(self.powerOptionsPage)
self.powerDescriptionLabel.setObjectName(u"powerDescriptionLabel")
self.powerOptionsForm.setWidget(4, QFormLayout.ItemRole.LabelRole, self.powerDescriptionLabel)
self.powerDescriptionEdit = QPlainTextEdit(self.powerOptionsPage)
self.powerDescriptionEdit.setObjectName(u"powerDescriptionEdit")
self.powerOptionsForm.setWidget(4, QFormLayout.ItemRole.FieldRole, self.powerDescriptionEdit)
self.typeOptionsStack.addWidget(self.powerOptionsPage)
self.unsupportedTypePage = QWidget()
self.unsupportedTypePage.setObjectName(u"unsupportedTypePage")
self.unsupportedTypeLayout = QVBoxLayout(self.unsupportedTypePage)
self.unsupportedTypeLayout.setObjectName(u"unsupportedTypeLayout")
self.unsupportedTypeLabel = QLabel(self.unsupportedTypePage)
self.unsupportedTypeLabel.setObjectName(u"unsupportedTypeLabel")
self.unsupportedTypeLabel.setWordWrap(True)
self.unsupportedTypeLayout.addWidget(self.unsupportedTypeLabel)
self.typeOptionsStack.addWidget(self.unsupportedTypePage)
self.portDetailsForm.setWidget(4, QFormLayout.ItemRole.SpanningRole, self.typeOptionsStack)
self.portSplitter.addWidget(self.portDetailsPanel)
@@ -129,14 +278,33 @@ class Ui_PortOptionsDialog(object):
self.addPortButton.setText(QCoreApplication.translate("PortOptionsDialog", u"Add Port", None))
self.removePortButton.setText(QCoreApplication.translate("PortOptionsDialog", u"Remove Port", None))
self.nameLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Name:", None))
self.typeLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Type:", None))
self.typeLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Port type:", None))
self.typeCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"Signal", None))
self.orientationLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Orientation:", None))
self.orientationCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"Input", None))
self.orientationCombo.setItemText(1, QCoreApplication.translate("PortOptionsDialog", u"Output", None))
self.orientationCombo.setItemText(2, QCoreApplication.translate("PortOptionsDialog", u"Indifferent", None))
self.multipleConnectionsCheckBox.setText(QCoreApplication.translate("PortOptionsDialog", u"Allow multiple connections", None))
self.positionHintLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"New ports start at (0, 0) in the icon editor.", None))
self.valueTypeLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Type:", None))
self.valueTypeCombo.setItemText(0, QCoreApplication.translate("PortOptionsDialog", u"real", None))
self.valueTypeCombo.setItemText(1, QCoreApplication.translate("PortOptionsDialog", u"integer", None))
self.valueTypeCombo.setItemText(2, QCoreApplication.translate("PortOptionsDialog", u"boolean", None))
self.valueTypeCombo.setItemText(3, QCoreApplication.translate("PortOptionsDialog", u"string", None))
self.quantityLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Quantity:", None))
self.unitLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Unit:", None))
self.rowsColumnsLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Matrix size:", None))
self.descriptionLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Description:", None))
self.domainLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Domain:", None))
self.effortLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Effort:", None))
self.effortValueLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"p.e", None))
self.flowLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Flow:", None))
self.flowValueLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"p.f", None))
self.causalityLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Causality:", None))
self.powerDescriptionLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"Description:", None))
self.unsupportedTypeLabel.setText(QCoreApplication.translate("PortOptionsDialog", u"This port type does not have an options editor yet.", None))
# retranslateUi

View File

@@ -15,12 +15,12 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView, QApplication, QDialog,
QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout,
QHeaderView, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QPushButton, QSizePolicy, QSpacerItem,
QSpinBox, QTabWidget, QTableWidget, QTableWidgetItem,
QVBoxLayout, QWidget)
from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView, QApplication, QCheckBox,
QDialog, QDialogButtonBox, QFormLayout, QGroupBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QPushButton, QSizePolicy,
QSpacerItem, QSpinBox, QTabWidget, QTableWidget,
QTableWidgetItem, QVBoxLayout, QWidget)
class Ui_SettingsDialog(object):
def setupUi(self, SettingsDialog):
@@ -104,6 +104,34 @@ class Ui_SettingsDialog(object):
self.generalLayout.addItem(self.generalSpacer)
self.settingsTabs.addTab(self.generalTab, "")
self.bondGraphTab = QWidget()
self.bondGraphTab.setObjectName(u"bondGraphTab")
self.bondGraphLayout = QVBoxLayout(self.bondGraphTab)
self.bondGraphLayout.setObjectName(u"bondGraphLayout")
self.causalityGroupBox = QGroupBox(self.bondGraphTab)
self.causalityGroupBox.setObjectName(u"causalityGroupBox")
self.causalityLayout = QVBoxLayout(self.causalityGroupBox)
self.causalityLayout.setObjectName(u"causalityLayout")
self.automaticCausalityCheckBox = QCheckBox(self.causalityGroupBox)
self.automaticCausalityCheckBox.setObjectName(u"automaticCausalityCheckBox")
self.automaticCausalityCheckBox.setChecked(True)
self.causalityLayout.addWidget(self.automaticCausalityCheckBox)
self.automaticCausalityHintLabel = QLabel(self.causalityGroupBox)
self.automaticCausalityHintLabel.setObjectName(u"automaticCausalityHintLabel")
self.automaticCausalityHintLabel.setWordWrap(True)
self.causalityLayout.addWidget(self.automaticCausalityHintLabel)
self.bondGraphLayout.addWidget(self.causalityGroupBox)
self.bondGraphSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)
self.bondGraphLayout.addItem(self.bondGraphSpacer)
self.settingsTabs.addTab(self.bondGraphTab, "")
self.simulationTab = QWidget()
self.simulationTab.setObjectName(u"simulationTab")
self.simulationTabLayout = QVBoxLayout(self.simulationTab)
@@ -247,6 +275,10 @@ class Ui_SettingsDialog(object):
self.iconGridLabel.setText(QCoreApplication.translate("SettingsDialog", u"Icon grid size:", None))
self.iconGridSpinBox.setSuffix(QCoreApplication.translate("SettingsDialog", u" units", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.generalTab), QCoreApplication.translate("SettingsDialog", u"General", None))
self.causalityGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"Causality", None))
self.automaticCausalityCheckBox.setText(QCoreApplication.translate("SettingsDialog", u"Infer causality when connections are added or removed", None))
self.automaticCausalityHintLabel.setText(QCoreApplication.translate("SettingsDialog", u"When disabled, displayed causalities are updated only when compiling, exporting, or running the model.", None))
self.settingsTabs.setTabText(self.settingsTabs.indexOf(self.bondGraphTab), QCoreApplication.translate("SettingsDialog", u"Bond graph", None))
self.openModelicaGroupBox.setTitle(QCoreApplication.translate("SettingsDialog", u"OpenModelica", None))
self.openModelicaPathLabel.setText(QCoreApplication.translate("SettingsDialog", u"OpenModelica executable:", None))
self.openModelicaPathEdit.setPlaceholderText(QCoreApplication.translate("SettingsDialog", u"Leave empty to find omc on PATH", None))

View File

@@ -16,10 +16,11 @@ from PySide6.QtGui import (QAction, QBrush, QColor, QConicalGradient,
QIcon, QImage, QKeySequence, QLinearGradient,
QPainter, QPalette, QPixmap, QRadialGradient,
QTransform)
from PySide6.QtWidgets import (QApplication, QDockWidget, QHBoxLayout, QLabel,
QListWidget, QListWidgetItem, QMainWindow, QMenu,
QMenuBar, QProgressBar, QSizePolicy, QToolBar,
QVBoxLayout, QWidget)
from PySide6.QtWidgets import (QAbstractItemView, QApplication, QDockWidget, QHBoxLayout,
QHeaderView, QLabel, QListWidget, QListWidgetItem,
QMainWindow, QMenu, QMenuBar, QProgressBar,
QSizePolicy, QTabWidget, QToolBar, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget)
from . import resources_rc
class Ui_SimulationWindow(object):
@@ -60,15 +61,26 @@ class Ui_SimulationWindow(object):
self.actionToggleResults.setObjectName(u"actionToggleResults")
self.actionToggleResults.setCheckable(True)
self.actionToggleResults.setChecked(True)
self.actionAddGraph = QAction(SimulationWindow)
self.actionAddGraph.setObjectName(u"actionAddGraph")
icon5 = QIcon()
icon5.addFile(u":/icons/icons/list-add.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionAddGraph.setIcon(icon5)
self.actionRemoveGraph = QAction(SimulationWindow)
self.actionRemoveGraph.setObjectName(u"actionRemoveGraph")
icon6 = QIcon()
icon6.addFile(u":/icons/icons/list-remove.png", QSize(), QIcon.Mode.Normal, QIcon.State.Off)
self.actionRemoveGraph.setIcon(icon6)
self.centralWidget = QWidget(SimulationWindow)
self.centralWidget.setObjectName(u"centralWidget")
self.resultsLayout = QVBoxLayout(self.centralWidget)
self.resultsLayout.setObjectName(u"resultsLayout")
self.resultsPlaceholder = QLabel(self.centralWidget)
self.resultsPlaceholder.setObjectName(u"resultsPlaceholder")
self.resultsPlaceholder.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.graphTabs = QTabWidget(self.centralWidget)
self.graphTabs.setObjectName(u"graphTabs")
self.graphTabs.setTabsClosable(False)
self.graphTabs.setMovable(True)
self.resultsLayout.addWidget(self.resultsPlaceholder)
self.resultsLayout.addWidget(self.graphTabs)
SimulationWindow.setCentralWidget(self.centralWidget)
self.menuBar = QMenuBar(SimulationWindow)
@@ -84,7 +96,13 @@ class Ui_SimulationWindow(object):
self.menuToolbars.setObjectName(u"menuToolbars")
self.menuHelp = QMenu(self.menuBar)
self.menuHelp.setObjectName(u"menuHelp")
self.menuGraph = QMenu(self.menuBar)
self.menuGraph.setObjectName(u"menuGraph")
SimulationWindow.setMenuBar(self.menuBar)
self.workspaceToolbar = QToolBar(SimulationWindow)
self.workspaceToolbar.setObjectName(u"workspaceToolbar")
self.workspaceToolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
SimulationWindow.addToolBar(Qt.ToolBarArea.TopToolBarArea, self.workspaceToolbar)
self.fileToolbar = QToolBar(SimulationWindow)
self.fileToolbar.setObjectName(u"fileToolbar")
self.fileToolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly)
@@ -130,9 +148,26 @@ class Ui_SimulationWindow(object):
self.logDock.setWidget(self.logDockContents)
SimulationWindow.addDockWidget(Qt.DockWidgetArea.BottomDockWidgetArea, self.logDock)
self.signalsDock = QDockWidget(SimulationWindow)
self.signalsDock.setObjectName(u"signalsDock")
self.signalsDockContents = QWidget()
self.signalsDockContents.setObjectName(u"signalsDockContents")
self.signalsLayout = QVBoxLayout(self.signalsDockContents)
self.signalsLayout.setObjectName(u"signalsLayout")
self.signalsTree = QTreeWidget(self.signalsDockContents)
self.signalsTree.setObjectName(u"signalsTree")
self.signalsTree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.signalsTree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.signalsTree.setHeaderHidden(True)
self.signalsLayout.addWidget(self.signalsTree)
self.signalsDock.setWidget(self.signalsDockContents)
SimulationWindow.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self.signalsDock)
self.menuBar.addAction(self.menuFile.menuAction())
self.menuBar.addAction(self.menuView.menuAction())
self.menuBar.addAction(self.menuGraph.menuAction())
self.menuBar.addAction(self.menuHelp.menuAction())
self.menuFile.addAction(self.actionOpen)
self.menuFile.addAction(self.actionSave)
@@ -145,10 +180,14 @@ class Ui_SimulationWindow(object):
self.menuView.addAction(self.menuToolbars.menuAction())
self.menuHelp.addAction(self.actionAbout)
self.menuHelp.addAction(self.actionAboutQt)
self.menuGraph.addAction(self.actionAddGraph)
self.menuGraph.addAction(self.actionRemoveGraph)
self.workspaceToolbar.addAction(self.actionAddGraph)
self.workspaceToolbar.addAction(self.actionRemoveGraph)
self.fileToolbar.addAction(self.actionClear)
self.fileToolbar.addAction(self.actionOpen)
self.fileToolbar.addAction(self.actionSave)
self.fileToolbar.addAction(self.actionSaveAs)
self.fileToolbar.addAction(self.actionClear)
self.retranslateUi(SimulationWindow)
@@ -177,17 +216,29 @@ class Ui_SimulationWindow(object):
self.actionAbout.setText(QCoreApplication.translate("SimulationWindow", u"&About Simulation Window", None))
self.actionAboutQt.setText(QCoreApplication.translate("SimulationWindow", u"About &Qt", None))
self.actionToggleResults.setText(QCoreApplication.translate("SimulationWindow", u"Results", None))
self.resultsPlaceholder.setText(QCoreApplication.translate("SimulationWindow", u"Simulation graphs and result controls can be added here.", None))
self.actionAddGraph.setText(QCoreApplication.translate("SimulationWindow", u"Add Graph", None))
#if QT_CONFIG(statustip)
self.actionAddGraph.setStatusTip(QCoreApplication.translate("SimulationWindow", u"Add a graph workspace tab", None))
#endif // QT_CONFIG(statustip)
self.actionRemoveGraph.setText(QCoreApplication.translate("SimulationWindow", u"Remove Current Graph", None))
#if QT_CONFIG(statustip)
self.actionRemoveGraph.setStatusTip(QCoreApplication.translate("SimulationWindow", u"Remove the current graph workspace tab", None))
#endif // QT_CONFIG(statustip)
self.menuFile.setTitle(QCoreApplication.translate("SimulationWindow", u"&File", None))
self.menuView.setTitle(QCoreApplication.translate("SimulationWindow", u"&View", None))
self.menuPanels.setTitle(QCoreApplication.translate("SimulationWindow", u"&Panels", None))
self.menuToolbars.setTitle(QCoreApplication.translate("SimulationWindow", u"&Toolbars", None))
self.menuHelp.setTitle(QCoreApplication.translate("SimulationWindow", u"&Help", None))
self.menuGraph.setTitle(QCoreApplication.translate("SimulationWindow", u"&Graph", None))
self.workspaceToolbar.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Workspace", None))
self.fileToolbar.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"File", None))
self.statusDock.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Status", None))
self.timeLabel.setText(QCoreApplication.translate("SimulationWindow", u"Time: 0 s", None))
self.progressBar.setFormat(QCoreApplication.translate("SimulationWindow", u"%p%", None))
self.statusLabel.setText(QCoreApplication.translate("SimulationWindow", u"No simulation has been run yet.", None))
self.logDock.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Log", None))
self.signalsDock.setWindowTitle(QCoreApplication.translate("SimulationWindow", u"Signals", None))
___qtreewidgetitem = self.signalsTree.headerItem()
___qtreewidgetitem.setText(0, QCoreApplication.translate("SimulationWindow", u"Signal", None))
# retranslateUi

View File

@@ -15,11 +15,11 @@ from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor,
QFont, QFontDatabase, QGradient, QIcon,
QImage, QKeySequence, QLinearGradient, QPainter,
QPalette, QPixmap, QRadialGradient, QTransform)
from PySide6.QtWidgets import (QAbstractItemView, QApplication, QGroupBox, QHBoxLayout,
QHeaderView, QPushButton, QSizePolicy, QSpacerItem,
QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout,
QWidget)
from PySide6.QtWidgets import (QApplication, QGroupBox, QHBoxLayout, QSizePolicy,
QSplitter, QVBoxLayout, QWidget)
from bedit.gui.dialogs.parameter_options import ParameterEditor
from bedit.gui.dialogs.port_options import PortEditor
from bedit.gui.editors.openmodelica import OpenModelicaEditor
class Ui_TextDefinitionEditor(object):
@@ -34,7 +34,31 @@ class Ui_TextDefinitionEditor(object):
self.columnSplitter.setObjectName(u"columnSplitter")
self.columnSplitter.setOrientation(Qt.Orientation.Horizontal)
self.columnSplitter.setChildrenCollapsible(False)
self.equationsGroup = QGroupBox(self.columnSplitter)
self.sourceSplitter = QSplitter(self.columnSplitter)
self.sourceSplitter.setObjectName(u"sourceSplitter")
self.sourceSplitter.setOrientation(Qt.Orientation.Vertical)
self.sourceSplitter.setChildrenCollapsible(False)
self.declarationsGroup = QGroupBox(self.sourceSplitter)
self.declarationsGroup.setObjectName(u"declarationsGroup")
self.declarationsLayout = QVBoxLayout(self.declarationsGroup)
self.declarationsLayout.setObjectName(u"declarationsLayout")
self.declarationsEdit = OpenModelicaEditor(self.declarationsGroup)
self.declarationsEdit.setObjectName(u"declarationsEdit")
self.declarationsLayout.addWidget(self.declarationsEdit)
self.sourceSplitter.addWidget(self.declarationsGroup)
self.initialEquationsGroup = QGroupBox(self.sourceSplitter)
self.initialEquationsGroup.setObjectName(u"initialEquationsGroup")
self.initialEquationsLayout = QVBoxLayout(self.initialEquationsGroup)
self.initialEquationsLayout.setObjectName(u"initialEquationsLayout")
self.initialEquationsEdit = OpenModelicaEditor(self.initialEquationsGroup)
self.initialEquationsEdit.setObjectName(u"initialEquationsEdit")
self.initialEquationsLayout.addWidget(self.initialEquationsEdit)
self.sourceSplitter.addWidget(self.initialEquationsGroup)
self.equationsGroup = QGroupBox(self.sourceSplitter)
self.equationsGroup.setObjectName(u"equationsGroup")
self.equationsLayout = QVBoxLayout(self.equationsGroup)
self.equationsLayout.setObjectName(u"equationsLayout")
@@ -43,7 +67,8 @@ class Ui_TextDefinitionEditor(object):
self.equationsLayout.addWidget(self.equationsEdit)
self.columnSplitter.addWidget(self.equationsGroup)
self.sourceSplitter.addWidget(self.equationsGroup)
self.columnSplitter.addWidget(self.sourceSplitter)
self.definitionSplitter = QSplitter(self.columnSplitter)
self.definitionSplitter.setObjectName(u"definitionSplitter")
self.definitionSplitter.setOrientation(Qt.Orientation.Vertical)
@@ -52,82 +77,20 @@ class Ui_TextDefinitionEditor(object):
self.portsGroup.setObjectName(u"portsGroup")
self.portsLayout = QVBoxLayout(self.portsGroup)
self.portsLayout.setObjectName(u"portsLayout")
self.portsTable = QTableWidget(self.portsGroup)
if (self.portsTable.columnCount() < 4):
self.portsTable.setColumnCount(4)
__qtablewidgetitem = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(0, __qtablewidgetitem)
__qtablewidgetitem1 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(1, __qtablewidgetitem1)
__qtablewidgetitem2 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(2, __qtablewidgetitem2)
__qtablewidgetitem3 = QTableWidgetItem()
self.portsTable.setHorizontalHeaderItem(3, __qtablewidgetitem3)
self.portsTable.setObjectName(u"portsTable")
self.portsTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.portsTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.portsTable.setColumnCount(4)
self.portEditor = PortEditor(self.portsGroup)
self.portEditor.setObjectName(u"portEditor")
self.portsLayout.addWidget(self.portsTable)
self.portButtonsLayout = QHBoxLayout()
self.portButtonsLayout.setObjectName(u"portButtonsLayout")
self.addPortButton = QPushButton(self.portsGroup)
self.addPortButton.setObjectName(u"addPortButton")
self.portButtonsLayout.addWidget(self.addPortButton)
self.removePortButton = QPushButton(self.portsGroup)
self.removePortButton.setObjectName(u"removePortButton")
self.portButtonsLayout.addWidget(self.removePortButton)
self.portButtonSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.portButtonsLayout.addItem(self.portButtonSpacer)
self.portsLayout.addLayout(self.portButtonsLayout)
self.portsLayout.addWidget(self.portEditor)
self.definitionSplitter.addWidget(self.portsGroup)
self.parametersGroup = QGroupBox(self.definitionSplitter)
self.parametersGroup.setObjectName(u"parametersGroup")
self.parametersLayout = QVBoxLayout(self.parametersGroup)
self.parametersLayout.setObjectName(u"parametersLayout")
self.parametersTable = QTableWidget(self.parametersGroup)
if (self.parametersTable.columnCount() < 3):
self.parametersTable.setColumnCount(3)
__qtablewidgetitem4 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(0, __qtablewidgetitem4)
__qtablewidgetitem5 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(1, __qtablewidgetitem5)
__qtablewidgetitem6 = QTableWidgetItem()
self.parametersTable.setHorizontalHeaderItem(2, __qtablewidgetitem6)
self.parametersTable.setObjectName(u"parametersTable")
self.parametersTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.parametersTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.parametersTable.setColumnCount(3)
self.parameterEditor = ParameterEditor(self.parametersGroup)
self.parameterEditor.setObjectName(u"parameterEditor")
self.parametersLayout.addWidget(self.parametersTable)
self.parameterButtonsLayout = QHBoxLayout()
self.parameterButtonsLayout.setObjectName(u"parameterButtonsLayout")
self.addParameterButton = QPushButton(self.parametersGroup)
self.addParameterButton.setObjectName(u"addParameterButton")
self.parameterButtonsLayout.addWidget(self.addParameterButton)
self.removeParameterButton = QPushButton(self.parametersGroup)
self.removeParameterButton.setObjectName(u"removeParameterButton")
self.parameterButtonsLayout.addWidget(self.removeParameterButton)
self.parameterButtonSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
self.parameterButtonsLayout.addItem(self.parameterButtonSpacer)
self.parametersLayout.addLayout(self.parameterButtonsLayout)
self.parametersLayout.addWidget(self.parameterEditor)
self.definitionSplitter.addWidget(self.parametersGroup)
self.columnSplitter.addWidget(self.definitionSplitter)
@@ -141,27 +104,11 @@ class Ui_TextDefinitionEditor(object):
# setupUi
def retranslateUi(self, TextDefinitionEditor):
self.declarationsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Declarations", None))
self.initialEquationsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Initial Equations", None))
self.equationsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Equations", None))
self.portsGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Ports", None))
___qtablewidgetitem = self.portsTable.horizontalHeaderItem(0)
___qtablewidgetitem.setText(QCoreApplication.translate("TextDefinitionEditor", u"Name", None))
___qtablewidgetitem1 = self.portsTable.horizontalHeaderItem(1)
___qtablewidgetitem1.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem2 = self.portsTable.horizontalHeaderItem(2)
___qtablewidgetitem2.setText(QCoreApplication.translate("TextDefinitionEditor", u"Orientation", None))
___qtablewidgetitem3 = self.portsTable.horizontalHeaderItem(3)
___qtablewidgetitem3.setText(QCoreApplication.translate("TextDefinitionEditor", u"Multiple", None))
self.addPortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Port", None))
self.removePortButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Port", None))
self.parametersGroup.setTitle(QCoreApplication.translate("TextDefinitionEditor", u"Parameters", None))
___qtablewidgetitem4 = self.parametersTable.horizontalHeaderItem(0)
___qtablewidgetitem4.setText(QCoreApplication.translate("TextDefinitionEditor", u"Name", None))
___qtablewidgetitem5 = self.parametersTable.horizontalHeaderItem(1)
___qtablewidgetitem5.setText(QCoreApplication.translate("TextDefinitionEditor", u"Type", None))
___qtablewidgetitem6 = self.parametersTable.horizontalHeaderItem(2)
___qtablewidgetitem6.setText(QCoreApplication.translate("TextDefinitionEditor", u"Value", None))
self.addParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Add Parameter", None))
self.removeParameterButton.setText(QCoreApplication.translate("TextDefinitionEditor", u"Remove Parameter", None))
pass
# retranslateUi

View File

@@ -1,8 +1,12 @@
from dataclasses import dataclass
from typing import Literal
from PySide6.QtCore import Qt
ArrowStyle = Literal["open", "half", "filled"]
@dataclass(frozen=True)
class ConnectionStyle:
color: str = "#285f9e"
@@ -13,11 +17,17 @@ class ConnectionStyle:
arrow_at_source: bool = False
arrow_at_target: bool = True
arrow_size: float = 10.0
arrow_style: ArrowStyle = "filled"
def __post_init__(self) -> None:
if self.arrow_style not in {"open", "half", "filled"}:
raise ValueError(f"Unknown connection arrow style: {self.arrow_style}")
# This is the intentional code-level styling point for every port/connection type.
CONNECTION_STYLES: dict[str, ConnectionStyle] = {
"signal": ConnectionStyle(),
"power": ConnectionStyle(width=3.0, arrow_style="half", color="#000000", arrow_size=20.0),
}

View File

@@ -1,6 +1,6 @@
from copy import deepcopy
from PySide6.QtCore import QPointF, QRectF, QSizeF, Qt
from PySide6.QtCore import QPointF, QRectF, QSizeF, Qt, QTimer
from PySide6.QtGui import QColor, QPainter, QPainterPath, QPen, QPolygonF
from PySide6.QtWidgets import (
QColorDialog,
@@ -458,8 +458,7 @@ class IconEditorDialog(QDialog):
self.ui.setupUi(self)
self.setWindowTitle(f"Icon Editor — {component.name}")
self.icon = Icon.from_dict(component.icon.to_dict())
self.inputs = deepcopy(component.inputs)
self.outputs = deepcopy(component.outputs)
self.ports = deepcopy(component.ports)
self.tool_group = QButtonGroup(self)
self.tool_group.setExclusive(True)
self.tool_group.addButton(self.ui.pointerButton)
@@ -485,9 +484,17 @@ class IconEditorDialog(QDialog):
self.scene.addRect(self.scene.sceneRect(), QPen(QColor("#64748b"), 0)).setZValue(-100)
for element in self.icon.elements:
self.scene.addItem(ShapeItem(element))
self._add_ports(self.inputs, "input", 0.0)
self._add_ports(self.outputs, "output", self.icon.width)
self.view.center_icon()
input_side = [port for port in self.ports if port.orientation != "output"]
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
def showEvent(self, event) -> None: # noqa: N802 (Qt API name)
super().showEvent(event)
if self._initial_fit_pending:
self._initial_fit_pending = False
QTimer.singleShot(0, self.view.center_icon)
def _add_ports(self, ports: list[Port], direction: str, default_x: float) -> None:
spacing = self.icon.height / (len(ports) + 1)

View File

@@ -173,8 +173,16 @@ class ComponentGraphicsItem(QGraphicsObject):
| QGraphicsItem.GraphicsItemFlag.ItemIsSelectable
| QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
)
self.input_ports = self._create_ports(component.inputs, "target", 0.0)
self.output_ports = self._create_ports(component.outputs, "source", self.WIDTH)
self.input_ports = self._create_ports(
[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.setRotation(component.rotation)
self.name_label: NameLabelItem | None = None
@@ -371,8 +379,10 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
super().__init__()
self.connection_id = connection.id
self.name = connection.name
self.connection_type = connection.type
self.source_is_junction = connection.source.junction is not None
self.target_is_junction = connection.target.junction is not None
self.causality = connection.causality
self.controller = controller
self.style = style or ConnectionStyle()
self.start = QPointF()
@@ -387,6 +397,19 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.name_label: NameLabelItem | None = None
self.sync_name_label(connection)
def boundingRect(self) -> QRectF: # noqa: N802
"""Include custom arrowheads and causality marks in Qt's repaint area."""
decoration_margin = (
max(self.style.arrow_size, 6.0) + self.style.selected_width / 2 + 1.0
)
return super().boundingRect().adjusted(
-decoration_margin,
-decoration_margin,
decoration_margin,
decoration_margin,
)
def sync_name_label(self, connection: Connection) -> None:
visible = bool(connection.properties.get("showName", False))
if not visible:
@@ -417,7 +440,11 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
self.setSelected(True)
menu = QMenu()
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()
options_action = menu.addAction("Connection Options…")
selected = menu.exec(event.screenPos())
@@ -425,7 +452,7 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
scene = self.scene()
if isinstance(scene, GraphScene):
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()
if isinstance(scene, GraphScene):
scene.add_connection_junction(self.connection_id, event.scenePos())
@@ -485,22 +512,62 @@ class ConnectionGraphicsItem(QGraphicsPathItem):
handle.setVisible(self.isSelected())
@staticmethod
def _arrow(end: QPointF, direction: QPointF, size: float) -> QPolygonF:
def _arrow_points(
end: QPointF, direction: QPointF, size: float
) -> tuple[QPointF, QPointF, QPointF]:
length = max(0.001, (direction.x() ** 2 + direction.y() ** 2) ** 0.5)
unit = QPointF(direction.x() / length, direction.y() / length)
normal = QPointF(-unit.y(), unit.x())
base = end - unit * size
return QPolygonF([end, base + normal * size * 0.45, base - normal * size * 0.45])
return end, base + normal * size * 0.45, base - normal * size * 0.45
def _draw_arrow(
self, painter: QPainter, end: QPointF, direction: QPointF
) -> None:
tip, left, right = self._arrow_points(
end, direction, self.style.arrow_size
)
color = self.pen().color()
if self.style.arrow_style == "filled":
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(color)
painter.drawPolygon(QPolygonF([tip, left, right]))
return
painter.setPen(QPen(color, self.pen().widthF(), Qt.PenStyle.SolidLine))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawLine(tip, left)
if self.style.arrow_style == "open":
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:
super().paint(painter, option, widget)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(self.pen().color())
if self.style.arrow_at_target and not self.target_is_junction:
painter.drawPolygon(self._arrow(self.end, self.end_direction, self.style.arrow_size))
self._draw_arrow(painter, self.end, self.end_direction)
if self.style.arrow_at_source and not self.source_is_junction:
painter.drawPolygon(
self._arrow(self.start, -self.start_direction, self.style.arrow_size)
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",
)
@@ -882,12 +949,12 @@ class GraphScene(QGraphicsScene):
owner = self.controller.active_component
if owner is None or owner.implementation_kind != "graph":
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)
self.addItem(item)
item.setPos(port.x, port.y)
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)
self.addItem(item)
item.setPos(port.x, port.y)
@@ -1207,9 +1274,23 @@ class GraphScene(QGraphicsScene):
def add_pairs(
source_item: ComponentGraphicsItem, target_item: ComponentGraphicsItem
) -> None:
for output in source_item.component.outputs:
for input_port in target_item.component.inputs:
if not PortTypeRegistry.compatible(output.type, input_port.type):
source_ports = [
port
for port in source_item.component.ports
if port.orientation in {"output", "indifferent"}
]
for output in source_ports:
for input_port in (
port
for port in target_item.component.ports
if port.orientation in {"input", "indifferent"}
):
if not PortTypeRegistry.compatible(
output.type,
input_port.type,
output.domain,
input_port.domain,
):
continue
source = Endpoint(block=source_item.component_id, port=output.id)
target = Endpoint(block=target_item.component_id, port=input_port.id)
@@ -1238,9 +1319,15 @@ class GraphScene(QGraphicsScene):
choices: list[ConnectionChoice] = []
interface = Endpoint(interface=terminal.port.id)
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)
if not PortTypeRegistry.compatible(terminal.port.type, port.type):
if not PortTypeRegistry.compatible(
terminal.port.type, port.type, terminal.port.domain, port.domain
):
continue
if not self.controller.endpoint_accepts_connection(interface, "source"):
continue
@@ -1254,9 +1341,16 @@ class GraphScene(QGraphicsScene):
)
)
else:
for port in component.component.outputs:
ports = [
port
for port in component.component.ports
if port.orientation in {"output", "indifferent"}
]
for port in ports:
source = Endpoint(block=component.component_id, port=port.id)
if not PortTypeRegistry.compatible(port.type, terminal.port.type):
if not PortTypeRegistry.compatible(
port.type, terminal.port.type, port.domain, terminal.port.domain
):
continue
if not self.controller.endpoint_accepts_connection(source, "source"):
continue
@@ -1278,7 +1372,11 @@ class GraphScene(QGraphicsScene):
) -> list[ConnectionChoice]:
choices: list[ConnectionChoice] = []
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)
if not PortTypeRegistry.compatible(junction.junction.type, port.type):
continue
@@ -1559,6 +1657,8 @@ class GraphScene(QGraphicsScene):
graphics = self.connection_items.get(connection_id)
if connection is None or graphics is None:
return
if connection.type == "power":
return
snapped = _snapped(position)
anchors = [graphics.start, *points, graphics.end]
@@ -1809,8 +1909,7 @@ class GraphWorkspaceView(QGraphicsView):
return
blocks: set[str] = set()
connections: set[str] = set()
inputs: set[str] = set()
outputs: set[str] = set()
ports: set[str] = set()
annotations: set[str] = set()
for item in self.scene().selectedItems():
if isinstance(item, ComponentGraphicsItem):
@@ -1818,10 +1917,10 @@ class GraphWorkspaceView(QGraphicsView):
elif isinstance(item, ConnectionGraphicsItem):
connections.add(item.connection_id)
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)):
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)
def has_selected_components(self) -> bool:

View File

@@ -181,6 +181,7 @@ class MainWindow(QMainWindow):
)
self.ui.actionGraphParameters.triggered.connect(self.show_graph_parameters)
self.ui.actionCompose.triggered.connect(self.compose_active_graph)
self.ui.actionExportModel.triggered.connect(self.export_model)
self.ui.actionSimulationWindow.triggered.connect(self.show_simulation_window)
self.ui.actionRunSimulation.triggered.connect(self.run_simulation)
self.document_controller.undo_stack.canUndoChanged.connect(self.ui.actionUndo.setEnabled)
@@ -261,6 +262,35 @@ class MainWindow(QMainWindow):
self.log.error("Composition failed: %s", error)
QMessageBox.warning(self, "Cannot compose", str(error))
@Slot()
def export_model(self) -> None:
try:
model_name, source = self.document_controller.compose_active_graph_source()
except ValueError as error:
self.log.error("Model export composition failed: %s", error)
QMessageBox.warning(self, "Cannot Export Model", str(error))
return
file_name, _selected_filter = QFileDialog.getSaveFileName(
self,
"Export OpenModelica Model",
f"{model_name}.mo",
"Modelica Models (*.mo);;All Files (*)",
)
if not file_name:
return
path = Path(file_name)
if not path.suffix:
path = path.with_suffix(".mo")
temporary_path = path.with_suffix(path.suffix + ".tmp")
try:
temporary_path.write_text(source, encoding="utf-8")
temporary_path.replace(path)
except OSError as error:
self.log.error("Could not export model %s: %s", path, error)
QMessageBox.critical(self, "Cannot Export Model", str(error))
return
self.log.info("Exported OpenModelica model to %s", path)
@Slot()
def show_simulation_window(self) -> None:
self._simulation_window.show()
@@ -274,7 +304,7 @@ class MainWindow(QMainWindow):
self.show_simulation_window()
try:
self.document_controller.run_simulation(*callbacks)
window.set_model_name(self.simulation.model_name)
window.prepare_run_model(self.simulation.model_name)
except Exception as error:
self.log.exception("Simulation run failed")
window.report_start_error(error)
@@ -307,6 +337,7 @@ class MainWindow(QMainWindow):
self.ui.actionSimulationSettings.setEnabled(False)
self.ui.actionGraphParameters.setEnabled(False)
self.ui.actionCompose.setEnabled(False)
self.ui.actionExportModel.setEnabled(False)
self.ui.actionRunSimulation.setEnabled(False)
self._update_edit_actions()
return
@@ -318,6 +349,7 @@ class MainWindow(QMainWindow):
self.ui.actionSimulationSettings.setEnabled(is_graph)
self.ui.actionGraphParameters.setEnabled(is_graph)
self.ui.actionCompose.setEnabled(is_graph)
self.ui.actionExportModel.setEnabled(is_graph)
self.ui.actionRunSimulation.setEnabled(is_graph)
self.ui.workspaceModeLabel.setText("Graph" if is_graph else "Text")
self.ui.workspaceStack.setCurrentWidget(
@@ -505,9 +537,10 @@ class MainWindow(QMainWindow):
if component is None:
return
self.ui.textDefinitionEditor.set_definition(
component.source.get("declarations", ""),
component.source.get("initialEquations", ""),
component.source.get("equations", ""),
component.inputs,
component.outputs,
component.ports,
component.parameters,
)
@@ -522,7 +555,8 @@ class MainWindow(QMainWindow):
answer = QMessageBox.question(
self,
"Apply text component changes?",
"The text component has unapplied equation, port, or parameter changes.",
"The text component has unapplied declaration, initial-equation, "
"equation, port, or parameter changes.",
QMessageBox.StandardButton.Apply
| QMessageBox.StandardButton.Discard
| QMessageBox.StandardButton.Cancel,
@@ -534,12 +568,13 @@ class MainWindow(QMainWindow):
@Slot()
def apply_text_definition(self) -> bool:
try:
inputs, outputs = self.ui.textDefinitionEditor.ports
ports = self.ui.textDefinitionEditor.ports
self._applying_text_definition = True
try:
self.document_controller.replace_active_text_definition(
inputs,
outputs,
ports,
self.ui.textDefinitionEditor.declarations,
self.ui.textDefinitionEditor.initial_equations,
self.ui.textDefinitionEditor.equations,
self.ui.textDefinitionEditor.parameters,
)
@@ -606,12 +641,23 @@ class MainWindow(QMainWindow):
)
if not filename:
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:
self.document_controller.load(Path(filename))
self.log.info("Opened document %s", filename)
self.document_controller.load(path)
self.log.info("Opened document %s", path)
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))
return False
return True
@Slot()
def save_document(self) -> bool:
@@ -672,6 +718,8 @@ class MainWindow(QMainWindow):
if scene is not None:
scene.update()
self.ui.graphView.viewport().update()
self.ui.textDefinitionEditor.ui.declarationsEdit.reload_highlighting()
self.ui.textDefinitionEditor.ui.initialEquationsEdit.reload_highlighting()
self.ui.textDefinitionEditor.ui.equationsEdit.reload_highlighting()
def _document_opened_changed(self, opened: bool) -> None:
@@ -779,9 +827,8 @@ class MainWindow(QMainWindow):
elif selected is ports_action:
dialog = PortOptionsDialog(component, self)
if dialog.exec() == dialog.DialogCode.Accepted:
old_inputs, old_outputs = deepcopy(component.inputs), deepcopy(component.outputs)
component.inputs = dialog.inputs
component.outputs = dialog.outputs
old_ports = deepcopy(component.ports)
component.ports = dialog.ports
library = next(
(
library
@@ -795,7 +842,7 @@ class MainWindow(QMainWindow):
library.document.validate()
DocumentSerializer.save(library.document, Path(library.source_path))
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)
QMessageBox.warning(self, "Cannot change library ports", str(error))
self.library_tree_model.rebuild()
@@ -837,7 +884,7 @@ class MainWindow(QMainWindow):
return
try:
self.document_controller.edit_component_ports(
component_id, dialog.inputs, dialog.outputs
component_id, dialog.ports
)
except ValueError as error:
self.log.error("Could not change component ports: %s", error)
@@ -873,8 +920,7 @@ class MainWindow(QMainWindow):
component_id,
dialog.ui.nameEdit.text().strip(),
dialog.edited_icon,
dialog.edited_inputs,
dialog.edited_outputs,
dialog.edited_ports,
dialog.ui.showSubtreeCheckBox.isChecked(),
dialog.ui.showNameCheckBox.isChecked(),
)
@@ -887,8 +933,7 @@ class MainWindow(QMainWindow):
owner = self.document_controller.active_component
if owner is None:
return
ports = owner.inputs if direction == "input" else owner.outputs
port = next((item for item in ports if item.id == port_id), None)
port = next((item for item in owner.ports if item.id == port_id), None)
if port is None:
return
dialog = ItemOptionsDialog(f"{direction.title()} Options", port.name, self)

View File

@@ -1,6 +1,6 @@
import json
from PySide6.QtCore import QByteArray, QMimeData, QModelIndex, Qt, Signal
from PySide6.QtCore import QByteArray, QMimeData, QModelIndex, QSignalBlocker, Qt, Signal
from PySide6.QtGui import QStandardItem, QStandardItemModel
from bedit.core.model import Component
@@ -80,8 +80,24 @@ class DocumentTreeModel(LibraryTreeModel):
controller.componentMoved.connect(lambda _component_id, _position: self.rebuild())
controller.connectionAdded.connect(lambda _connection_id: self.rebuild())
controller.connectionRemoved.connect(lambda _connection_id: self.rebuild())
controller.documentNameChanged.connect(self._document_name_changed)
self.itemChanged.connect(self._document_item_changed)
self.rebuild()
def _document_item_changed(self, item: QStandardItem) -> None:
if item.data(ITEM_KIND_ROLE) != "current-document":
return
try:
self.controller.rename_document(item.text())
except ValueError:
self.rebuild()
def _document_name_changed(self, name: str) -> None:
root = self.item(0)
if root is not None:
with QSignalBlocker(self):
root.setText(name)
def rebuild(self) -> None:
self.clear()
self.setHorizontalHeaderLabels(["Document"])

View File

@@ -1,17 +1,33 @@
import re
from pathlib import Path
from matplotlib.backend_bases import MouseButton
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg, NavigationToolbar2QT
from matplotlib.figure import Figure
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QFileDialog, QMainWindow, QMessageBox
from PySide6.QtGui import QCloseEvent
from PySide6.QtWidgets import (
QFileDialog,
QInputDialog,
QMainWindow,
QMenu,
QMessageBox,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from bedit.core.simulation.openmodelica import SimulationMessage, SimulationProgress
from bedit.core.simulation.results import (
SimulationExecutionResult,
SimulationGraph,
SimulationResults,
SimulationTrace,
load_simulation_results,
save_simulation_results,
)
from bedit.gui.generated.ui_simulation_window import Ui_SimulationWindow
from bedit.gui.preferences import application_settings
class SimulationWindow(QMainWindow):
@@ -26,9 +42,12 @@ class SimulationWindow(QMainWindow):
super().__init__(parent)
self.ui = Ui_SimulationWindow()
self.ui.setupUi(self)
self.settings = application_settings()
self._running = False
self._run_generation = 0
self._file_path: Path | None = None
self._rebuilding_graph_tabs = False
self._updating_signal_checks = False
self.results = SimulationResults()
self._connect_actions()
self._populate_view_menu()
@@ -36,6 +55,7 @@ class SimulationWindow(QMainWindow):
self.ui.statusDock, self.ui.logDock, Qt.Orientation.Vertical
)
self.ui.statusDock.setFixedHeight(self.ui.statusDock.sizeHint().height())
self._restore_window_layout()
self.progressReceived.connect(self._show_progress)
self.messageReceived.connect(self._show_message)
self.simulationFinished.connect(self._show_finished)
@@ -52,19 +72,57 @@ class SimulationWindow(QMainWindow):
lambda: QMessageBox.aboutQt(self, "About Qt Framework")
)
self.ui.actionToggleResults.toggled.connect(self.ui.centralWidget.setVisible)
self.ui.actionAddGraph.triggered.connect(self.add_graph_tab)
self.ui.actionRemoveGraph.triggered.connect(self.remove_current_graph_tab)
self.ui.graphTabs.tabBarDoubleClicked.connect(self.rename_graph_tab)
self.ui.graphTabs.tabBar().tabMoved.connect(self._move_graph_tab)
self.ui.graphTabs.currentChanged.connect(self._current_graph_changed)
self.ui.signalsTree.itemChanged.connect(self._signal_check_changed)
self.ui.signalsTree.customContextMenuRequested.connect(
self.show_signal_context_menu
)
def _populate_view_menu(self) -> None:
self.ui.menuPanels.addAction(self.ui.actionToggleResults)
for panel in (self.ui.statusDock, self.ui.logDock):
for panel in (self.ui.statusDock, self.ui.logDock, self.ui.signalsDock):
self.ui.menuPanels.addAction(panel.toggleViewAction())
self.ui.menuToolbars.addAction(self.ui.fileToolbar.toggleViewAction())
self.ui.menuToolbars.addAction(self.ui.workspaceToolbar.toggleViewAction())
def begin_run(self, model_name: str = "") -> tuple:
"""Reset the viewer and return callbacks bound to this run."""
def _restore_window_layout(self) -> None:
geometry = self.settings.value("simulationWindow/geometry")
if geometry is not None:
self.restoreGeometry(geometry)
state = self.settings.value("simulationWindow/state")
if state is not None:
self.restoreState(state)
results_visible = self.settings.value(
"simulationWindow/resultsVisible", True, type=bool
)
self.ui.actionToggleResults.setChecked(results_visible)
self.clear(model_name=model_name)
def _save_window_layout(self) -> None:
self.settings.setValue("simulationWindow/geometry", self.saveGeometry())
self.settings.setValue("simulationWindow/state", self.saveState())
self.settings.setValue(
"simulationWindow/resultsVisible",
self.ui.actionToggleResults.isChecked(),
)
self.settings.sync()
def begin_run(self) -> tuple:
"""Reset transient run state while retaining the current workspace."""
self._run_generation += 1
generation = self._run_generation
self._running = True
self._file_path = None
self.results.status = {}
self.results.messages.clear()
self.results.metadata.clear()
self.ui.progressBar.setValue(0)
self.ui.timeLabel.setText("Time: 0 s")
self.ui.messageList.clear()
self.ui.statusLabel.setText("Preparing simulation…")
return (
lambda progress: self._report_progress(generation, progress),
@@ -73,10 +131,19 @@ class SimulationWindow(QMainWindow):
lambda error: self._report_error(generation, error),
)
def set_model_name(self, model_name: str | None) -> None:
if model_name:
def prepare_run_model(self, model_name: str | None) -> None:
"""Retain graph configuration only when rerunning the same model."""
if not model_name:
return
if self.results.model_name != model_name:
self.results = SimulationResults(model_name=model_name)
self.clear_result_views()
self.load_result_views()
else:
self.results.model_name = model_name
self._update_title()
self.ui.statusLabel.setText("Preparing simulation…")
self._update_title()
def clear(self, checked: bool = False, *, model_name: str = "") -> None:
"""Discard the displayed run and prepare an empty results document."""
@@ -91,6 +158,7 @@ class SimulationWindow(QMainWindow):
self.ui.timeLabel.setText("Time: 0 s")
self.ui.messageList.clear()
self.clear_result_views()
self.load_result_views()
self._update_title()
def clear_result_views(self) -> None:
@@ -100,9 +168,10 @@ class SimulationWindow(QMainWindow):
``resultsLayout`` and reset here.
"""
self.ui.resultsPlaceholder.setText(
"Simulation graphs and result controls can be added here."
)
self._rebuilding_graph_tabs = True
self._clear_graph_tab_widgets()
self._rebuilding_graph_tabs = False
self.ui.signalsTree.clear()
def load_result_views(self) -> None:
"""Populate custom plots from ``self.results.data`` and traces.
@@ -110,14 +179,226 @@ class SimulationWindow(QMainWindow):
This is the intended integration point for a future plotting widget.
"""
data_count = len(self.results.data)
trace_count = len(self.results.traces)
if data_count or trace_count:
sample_count = len(next(iter(self.results.data.values()), []))
self.ui.resultsPlaceholder.setText(
f"{data_count} data column(s), {sample_count} sample(s), and "
f"{trace_count} configured trace(s) loaded; add graph rendering here."
self._rebuild_graph_tabs()
self._rebuild_signal_tree()
def _rebuild_graph_tabs(self) -> None:
current_page = self.ui.graphTabs.currentWidget()
current_graph_id = (
current_page.graph_id
if isinstance(current_page, GraphWorkspacePage)
else None
)
self._rebuilding_graph_tabs = True
self._clear_graph_tab_widgets()
for graph in self.results.graphs:
self.ui.graphTabs.addTab(
GraphWorkspacePage(graph, self.results), graph.title
)
if current_graph_id is not None:
for index, graph in enumerate(self.results.graphs):
if graph.id == current_graph_id:
self.ui.graphTabs.setCurrentIndex(index)
break
self._rebuilding_graph_tabs = False
self._update_graph_actions()
self._sync_signal_checks()
def _clear_graph_tab_widgets(self) -> None:
while self.ui.graphTabs.count():
page = self.ui.graphTabs.widget(0)
self.ui.graphTabs.removeTab(0)
page.deleteLater()
def add_graph_tab(self) -> None:
used_titles = {graph.title for graph in self.results.graphs}
number = 1
while f"Graph {number}" in used_titles:
number += 1
graph = SimulationGraph(title=f"Graph {number}")
self.results.graphs.append(graph)
index = self.ui.graphTabs.addTab(
GraphWorkspacePage(graph, self.results), graph.title
)
self.ui.graphTabs.setCurrentIndex(index)
self._update_graph_actions()
def remove_current_graph_tab(self) -> None:
index = self.ui.graphTabs.currentIndex()
if index < 0 or index >= len(self.results.graphs):
return
self.results.graphs.pop(index)
page = self.ui.graphTabs.widget(index)
self.ui.graphTabs.removeTab(index)
page.deleteLater()
self._update_graph_actions()
self._sync_signal_checks()
def rename_graph_tab(self, index: int) -> None:
if index < 0 or index >= len(self.results.graphs):
return
graph = self.results.graphs[index]
title, accepted = QInputDialog.getText(
self, "Rename Graph", "Title:", text=graph.title
)
title = title.strip()
if accepted and title:
graph.title = title
self.ui.graphTabs.setTabText(index, title)
page = self.ui.graphTabs.widget(index)
if isinstance(page, GraphWorkspacePage):
page.refresh_chart()
def _move_graph_tab(self, old_index: int, new_index: int) -> None:
if self._rebuilding_graph_tabs or old_index == new_index:
return
graph = self.results.graphs.pop(old_index)
self.results.graphs.insert(new_index, graph)
self._sync_signal_checks()
def _update_graph_actions(self) -> None:
self.ui.actionRemoveGraph.setEnabled(bool(self.results.graphs))
def _rebuild_signal_tree(self) -> None:
"""Build a hierarchy while retaining each leaf's complete signal name."""
tree = self.ui.signalsTree
self._updating_signal_checks = True
try:
tree.clear()
items: dict[tuple[str, ...], QTreeWidgetItem] = {}
for signal_name in sorted(self.results.data, key=str.casefold):
parts = _signal_tree_parts(signal_name)
if not parts:
continue
parent = tree.invisibleRootItem()
for depth, part in enumerate(parts, start=1):
path = parts[:depth]
item = items.get(path)
if item is None:
item = QTreeWidgetItem(parent, [part])
items[path] = item
parent = item
parent.setData(0, Qt.ItemDataRole.UserRole, signal_name)
metadata = self.results.signal_metadata.get(signal_name, {})
unit = metadata.get("unit", "")
if unit:
parent.setText(0, f"{parts[-1]} [{unit}]")
tooltip = signal_name
details = [
metadata.get("quantity", ""),
f"Unit: {unit}" if unit else "",
metadata.get("description", ""),
]
details = [detail for detail in details if detail]
if details:
tooltip += "\n" + "\n".join(details)
parent.setToolTip(0, tooltip)
parent.setFlags(parent.flags() | Qt.ItemFlag.ItemIsUserCheckable)
parent.setCheckState(0, Qt.CheckState.Unchecked)
tree.expandToDepth(0)
finally:
self._updating_signal_checks = False
self._sync_signal_checks()
def _current_graph_changed(self, _index: int) -> None:
if not self._rebuilding_graph_tabs:
self._sync_signal_checks()
def _current_graph(self) -> SimulationGraph | None:
index = self.ui.graphTabs.currentIndex()
if 0 <= index < len(self.results.graphs):
return self.results.graphs[index]
return None
def _sync_signal_checks(self) -> None:
graph = self._current_graph()
enabled = {trace.name for trace in graph.traces} if graph else set()
self._updating_signal_checks = True
try:
root = self.ui.signalsTree.invisibleRootItem()
pending = [root.child(index) for index in range(root.childCount())]
while pending:
item = pending.pop()
pending.extend(
item.child(index) for index in range(item.childCount())
)
signal_name = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(signal_name, str):
item.setCheckState(
0,
Qt.CheckState.Checked
if signal_name in enabled
else Qt.CheckState.Unchecked,
)
finally:
self._updating_signal_checks = False
self.ui.signalsTree.setEnabled(graph is not None)
def _signal_check_changed(self, item: QTreeWidgetItem, _column: int) -> None:
if self._updating_signal_checks:
return
signal_name = item.data(0, Qt.ItemDataRole.UserRole)
graph = self._current_graph()
if not isinstance(signal_name, str) or graph is None:
return
enabled = item.checkState(0) == Qt.CheckState.Checked
existing = next(
(trace for trace in graph.traces if trace.name == signal_name), None
)
if enabled and existing is None:
metadata = self.results.signal_metadata.get(signal_name, {})
graph.traces.append(
SimulationTrace(
name=signal_name,
y_label=metadata.get("quantity", ""),
unit=metadata.get("unit", ""),
)
)
elif not enabled and existing is not None:
graph.traces.remove(existing)
page = self.ui.graphTabs.currentWidget()
if isinstance(page, GraphWorkspacePage):
page.refresh_chart()
def selected_signal_names(self) -> list[str]:
"""Return full column names selected for future plotting actions."""
names = []
for item in self.ui.signalsTree.selectedItems():
name = item.data(0, Qt.ItemDataRole.UserRole)
if isinstance(name, str):
names.append(name)
return names
def show_signal_context_menu(self, position) -> None:
item = self.ui.signalsTree.itemAt(position)
signal_name = (
item.data(0, Qt.ItemDataRole.UserRole) if item is not None else None
)
graph = self._current_graph()
if not isinstance(signal_name, str) or graph is None:
return
menu = QMenu(self)
use_as_x_action = menu.addAction("Use as X Axis")
use_as_x_action.setCheckable(True)
use_as_x_action.setChecked(graph.x_axis == signal_name)
selected = menu.exec(
self.ui.signalsTree.viewport().mapToGlobal(position)
)
if selected is use_as_x_action:
self.set_x_axis_signal(signal_name)
def set_x_axis_signal(self, signal_name: str) -> None:
"""Set the current graph's persisted horizontal data column."""
graph = self._current_graph()
if graph is None or signal_name not in self.results.data:
return
graph.x_axis = signal_name
page = self.ui.graphTabs.currentWidget()
if isinstance(page, GraphWorkspacePage):
page.refresh_chart()
def open_results(self) -> None:
file_name, _selected_filter = QFileDialog.getOpenFileName(
@@ -231,6 +512,24 @@ class SimulationWindow(QMainWindow):
self.results.status.update(phase="Simulation finished", progress=10000)
if isinstance(result, SimulationExecutionResult):
self.results.data = result.data
self.results.signal_metadata = result.signal_metadata
available_signals = set(result.data)
for graph in self.results.graphs:
graph.traces = [
trace
for trace in graph.traces
if trace.name in available_signals
]
for trace in graph.traces:
metadata = result.signal_metadata.get(trace.name, {})
trace.unit = metadata.get("unit", "")
trace.y_label = metadata.get("quantity", "")
if graph.x_axis not in available_signals:
graph.x_axis = (
"time"
if "time" in available_signals
else next(iter(result.data), "")
)
self.results.metadata["processReturnCode"] = result.return_code
self.results.metadata["sourceResultFile"] = Path(result.result_file).name
else:
@@ -256,11 +555,221 @@ class SimulationWindow(QMainWindow):
"<p>View live progress and open or save simulation results.</p>",
)
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 (Qt API name)
self._save_window_layout()
super().closeEvent(event)
@property
def is_running(self) -> bool:
return self._running
class GraphWorkspacePage(QWidget):
"""Matplotlib view of one persisted simulation graph definition."""
def __init__(
self, graph: SimulationGraph, results: SimulationResults, parent=None
) -> None:
super().__init__(parent)
self.graph_id = graph.id
self.graph = graph
self.results = results
self.plot_layout = QVBoxLayout(self)
self.figure = Figure(layout="constrained")
self.canvas = InteractiveFigureCanvas(self.figure)
self.axes = self.figure.add_subplot(111)
self.canvas.axes = self.axes
self.navigation_toolbar = GraphNavigationToolbar(self.canvas, self)
self.canvas.navigation_toolbar = self.navigation_toolbar
self.plot_layout.addWidget(self.navigation_toolbar)
self.plot_layout.addWidget(self.canvas)
self.refresh_chart()
def refresh_chart(self) -> None:
self.axes.clear()
x_values = self.results.data.get(self.graph.x_axis)
for trace in self.graph.traces:
y_values = self.results.data.get(trace.name)
if y_values is None:
continue
horizontal = x_values if x_values is not None else range(len(y_values))
sample_count = min(len(horizontal), len(y_values))
color = trace.properties.get("color")
unit = trace.unit or self.results.signal_metadata.get(
trace.name, {}
).get("unit", "")
label = f"{trace.name} [{unit}]" if unit else trace.name
self.axes.plot(
list(horizontal)[:sample_count],
y_values[:sample_count],
label=label,
color=color if isinstance(color, str) and color else None,
)
self.axes.set_title(self.graph.title)
if x_values is not None:
x_unit = self.results.signal_metadata.get(
self.graph.x_axis, {}
).get("unit", "")
x_label = (
f"{self.graph.x_axis} [{x_unit}]"
if x_unit
else self.graph.x_axis
)
else:
x_label = "sample"
self.axes.set_xlabel(x_label)
units = {
trace.unit
or self.results.signal_metadata.get(trace.name, {}).get("unit", "")
for trace in self.graph.traces
if self.results.data.get(trace.name) is not None
}
units.discard("")
if len(units) == 1:
self.axes.set_ylabel(next(iter(units)))
self.axes.grid(True, alpha=0.25)
if self.axes.lines:
self.axes.legend()
self.axes.relim()
self.axes.autoscale_view()
self.canvas.set_home_view()
self.canvas.draw_idle()
class GraphNavigationToolbar(NavigationToolbar2QT):
"""Navigation toolbar whose Home action includes direct canvas navigation."""
def home(self, *args) -> None:
del args
self.canvas.reset_home_view()
class InteractiveFigureCanvas(FigureCanvasQTAgg):
"""Matplotlib canvas with always-available wheel zoom and drag pan."""
def __init__(self, figure: Figure) -> None:
super().__init__(figure)
self.axes = None
self.navigation_toolbar = None
self._pan_start = None
self._home_view = None
self.mpl_connect("scroll_event", self._zoom_at_cursor)
self.mpl_connect("button_press_event", self._start_pan)
self.mpl_connect("motion_notify_event", self._pan)
self.mpl_connect("button_release_event", self._finish_pan)
def _toolbar_is_active(self) -> bool:
return bool(
self.navigation_toolbar is not None
and self.navigation_toolbar.mode
)
def set_home_view(self) -> None:
if self.axes is not None:
self._home_view = (self.axes.get_xlim(), self.axes.get_ylim())
def reset_home_view(self) -> None:
if self.axes is None or self._home_view is None:
return
x_limits, y_limits = self._home_view
self.axes.set_xlim(x_limits)
self.axes.set_ylim(y_limits)
self.draw_idle()
def _zoom_at_cursor(self, event) -> None:
if (
self.axes is None
or event.inaxes is not self.axes
or event.xdata is None
or event.ydata is None
or self._toolbar_is_active()
):
return
scale = 0.8 if event.button == "up" else 1.25
left, right = self.axes.get_xlim()
bottom, top = self.axes.get_ylim()
self.axes.set_xlim(
event.xdata - (event.xdata - left) * scale,
event.xdata + (right - event.xdata) * scale,
)
self.axes.set_ylim(
event.ydata - (event.ydata - bottom) * scale,
event.ydata + (top - event.ydata) * scale,
)
self.draw_idle()
def _start_pan(self, event) -> None:
if (
self.axes is None
or event.inaxes is not self.axes
or event.button != MouseButton.LEFT
or self._toolbar_is_active()
):
return
self._pan_start = (
event.x,
event.y,
self.axes.get_xlim(),
self.axes.get_ylim(),
)
def _pan(self, event) -> None:
if (
self.axes is None
or self._pan_start is None
or event.x is None
or event.y is None
):
return
start_x, start_y, x_limits, y_limits = self._pan_start
width = max(self.axes.bbox.width, 1.0)
height = max(self.axes.bbox.height, 1.0)
delta_x = (event.x - start_x) * (x_limits[1] - x_limits[0]) / width
delta_y = (event.y - start_y) * (y_limits[1] - y_limits[0]) / height
self.axes.set_xlim(x_limits[0] - delta_x, x_limits[1] - delta_x)
self.axes.set_ylim(y_limits[0] - delta_y, y_limits[1] - delta_y)
self.draw_idle()
def _finish_pan(self, _event) -> None:
self._pan_start = None
def _safe_file_stem(model_name: str) -> str:
stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", model_name).strip("._")
return stem or "simulation"
def _signal_tree_parts(signal_name: str) -> tuple[str, ...]:
"""Split a result name into readable component, variable, and index levels.
OpenModelica emits state derivatives as names such as ``der(block.x)``.
Keep those traces available, but display them below a ``Derivatives`` group
on their owning component instead of creating a misleading top-level
``der(block`` branch.
"""
derivative = re.fullmatch(r"der\((.+)\)", signal_name)
if derivative is not None:
inner_name = derivative.group(1)
inner_parts = _signal_tree_parts(inner_name)
if inner_parts:
index_count = len(re.findall(r"\[[^\]]+\]", inner_name.rsplit(".", 1)[-1]))
variable_index = max(0, len(inner_parts) - index_count - 1)
return (
*inner_parts[:variable_index],
"Derivatives",
*inner_parts[variable_index:],
)
return ("Derivatives", signal_name)
parts: list[str] = []
for segment in signal_name.split("."):
if not segment:
continue
match = re.fullmatch(r"([^\[]+)((?:\[[^\]]+\])+)", segment)
if match is None:
parts.append(segment)
continue
parts.append(match.group(1))
parts.extend(re.findall(r"\[([^\]]+)\]", match.group(2)))
return tuple(parts)

View File

@@ -552,6 +552,7 @@
<addaction name="actionSimulationSettings"/>
<addaction name="actionGraphParameters"/>
<addaction name="actionCompose"/>
<addaction name="actionExportModel"/>
<addaction name="actionSimulationWindow"/>
<addaction name="actionRunSimulation"/>
</widget>
@@ -715,6 +716,18 @@
<string>Show the simulation results window</string>
</property>
</action>
<action name="actionExportModel">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/document-save-as.png</normaloff>:/icons/icons/document-save-as.png</iconset>
</property>
<property name="text">
<string>Export Model…</string>
</property>
<property name="statusTip">
<string>Save the composed OpenModelica model to a file</string>
</property>
</action>
<action name="actionNew">
<property name="icon">
<iconset resource="../resources/resources.qrc">

View File

@@ -2,13 +2,222 @@
<ui version="4.0">
<class>ParameterOptionsDialog</class>
<widget class="QDialog" name="ParameterOptionsDialog">
<property name="geometry"><rect><x>0</x><y>0</y><width>620</width><height>380</height></rect></property>
<property name="windowTitle"><string>Parameter Options</string></property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>720</width>
<height>328</height>
</rect>
</property>
<property name="windowTitle">
<string>Parameter Options</string>
</property>
<layout class="QVBoxLayout" name="dialogLayout">
<item><widget class="QSplitter" name="parameterSplitter"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><widget class="QWidget" name="parameterListPanel"><layout class="QVBoxLayout" name="parameterListLayout"><item><widget class="QListWidget" name="parameterList"/></item><item><layout class="QHBoxLayout" name="parameterButtonsLayout"><item><widget class="QPushButton" name="addParameterButton"><property name="text"><string>Add Parameter</string></property></widget></item><item><widget class="QPushButton" name="removeParameterButton"><property name="text"><string>Remove Parameter</string></property></widget></item></layout></item></layout></widget><widget class="QWidget" name="parameterDetailsPanel"><layout class="QFormLayout" name="parameterDetailsForm"><item row="0" column="0"><widget class="QLabel" name="nameLabel"><property name="text"><string>Name:</string></property></widget></item><item row="0" column="1"><widget class="QLineEdit" name="nameEdit"/></item><item row="1" column="0"><widget class="QLabel" name="typeLabel"><property name="text"><string>Type:</string></property></widget></item><item row="1" column="1"><widget class="QLineEdit" name="typeEdit"/></item><item row="2" column="0"><widget class="QLabel" name="valueLabel"><property name="text"><string>Value:</string></property></widget></item><item row="2" column="1"><widget class="QLineEdit" name="valueEdit"/></item></layout></widget></widget></item>
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set></property></widget></item>
<item>
<widget class="QSplitter" name="parameterSplitter">
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<widget class="QWidget" name="parameterListPanel">
<layout class="QVBoxLayout" name="parameterListLayout">
<item>
<widget class="QListWidget" name="parameterList"/>
</item>
<item>
<layout class="QHBoxLayout" name="parameterButtonsLayout">
<item>
<widget class="QPushButton" name="addParameterButton">
<property name="text">
<string>Add Parameter</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removeParameterButton">
<property name="text">
<string>Remove Parameter</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="parameterDetailsPanel">
<layout class="QFormLayout" name="parameterDetailsForm">
<property name="verticalSpacing">
<number>3</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="nameLabel">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="nameEdit"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="typeLabel">
<property name="text">
<string>Type:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="typeCombo">
<property name="editable">
<bool>true</bool>
</property>
<item>
<property name="text">
<string>real</string>
</property>
</item>
<item>
<property name="text">
<string>integer</string>
</property>
</item>
<item>
<property name="text">
<string>boolean</string>
</property>
</item>
<item>
<property name="text">
<string>string</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="columnsLabel">
<property name="text">
<string>Matrix size:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QSpinBox" name="columnsSpin">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>9999</number>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="rowsSpin">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>9999</number>
</property>
</widget>
</item>
</layout>
</item>
<item row="3" column="1">
<widget class="QLineEdit" name="valueEdit"/>
</item>
<item row="4" column="1">
<widget class="QComboBox" name="quantityCombo">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="QComboBox" name="unitCombo">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="6" column="1">
<widget class="QPlainTextEdit" name="descriptionEdit"/>
</item>
<item row="3" column="0">
<widget class="QLabel" name="valueLabel">
<property name="text">
<string>Value:</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="quantityLabel">
<property name="text">
<string>Quantity:</string>
</property>
</widget>
</item>
<item row="5" column="0">
<widget class="QLabel" name="unitLabel">
<property name="text">
<string>Unit:</string>
</property>
</widget>
</item>
<item row="6" column="0">
<widget class="QLabel" name="descriptionLabel">
<property name="text">
<string>Description:</string>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections><connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>ParameterOptionsDialog</receiver><slot>accept()</slot><hints/></connection><connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>ParameterOptionsDialog</receiver><slot>reject()</slot><hints/></connection></connections>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>ParameterOptionsDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>20</x>
<y>20</y>
</hint>
<hint type="destinationlabel">
<x>20</x>
<y>20</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>ParameterOptionsDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>20</x>
<y>20</y>
</hint>
<hint type="destinationlabel">
<x>20</x>
<y>20</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

@@ -2,43 +2,307 @@
<ui version="4.0">
<class>PortOptionsDialog</class>
<widget class="QDialog" name="PortOptionsDialog">
<property name="geometry"><rect><x>0</x><y>0</y><width>620</width><height>380</height></rect></property>
<property name="windowTitle"><string>Port Options</string></property>
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>760</width>
<height>421</height>
</rect>
</property>
<property name="windowTitle">
<string>Port Options</string>
</property>
<layout class="QVBoxLayout" name="dialogLayout">
<item>
<widget class="QSplitter" name="portSplitter">
<property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property>
<property name="orientation">
<enum>Qt::Orientation::Horizontal</enum>
</property>
<widget class="QWidget" name="portListPanel">
<layout class="QVBoxLayout" name="portListLayout">
<item><widget class="QListWidget" name="portList"/></item>
<item>
<widget class="QListWidget" name="portList"/>
</item>
<item>
<layout class="QHBoxLayout" name="portButtonsLayout">
<item><widget class="QPushButton" name="addPortButton"><property name="text"><string>Add Port</string></property></widget></item>
<item><widget class="QPushButton" name="removePortButton"><property name="text"><string>Remove Port</string></property></widget></item>
<item>
<widget class="QPushButton" name="addPortButton">
<property name="text">
<string>Add Port</string>
</property>
</widget>
</item>
<item>
<widget class="QPushButton" name="removePortButton">
<property name="text">
<string>Remove Port</string>
</property>
</widget>
</item>
</layout>
</item>
</layout>
</widget>
<widget class="QWidget" name="portDetailsPanel">
<layout class="QFormLayout" name="portDetailsForm">
<item row="0" column="0"><widget class="QLabel" name="nameLabel"><property name="text"><string>Name:</string></property></widget></item>
<item row="0" column="1"><widget class="QLineEdit" name="nameEdit"/></item>
<item row="1" column="0"><widget class="QLabel" name="typeLabel"><property name="text"><string>Type:</string></property></widget></item>
<item row="1" column="1"><widget class="QComboBox" name="typeCombo"><item><property name="text"><string>Signal</string></property></item></widget></item>
<item row="2" column="0"><widget class="QLabel" name="orientationLabel"><property name="text"><string>Orientation:</string></property></widget></item>
<item row="2" column="1"><widget class="QComboBox" name="orientationCombo"><item><property name="text"><string>Input</string></property></item><item><property name="text"><string>Output</string></property></item></widget></item>
<item row="3" column="0" colspan="2"><widget class="QCheckBox" name="multipleConnectionsCheckBox"><property name="text"><string>Allow multiple connections</string></property></widget></item>
<item row="4" column="0" colspan="2"><widget class="QLabel" name="positionHintLabel"><property name="text"><string>New ports start at (0, 0) in the icon editor.</string></property><property name="wordWrap"><bool>true</bool></property></widget></item>
<property name="verticalSpacing">
<number>3</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="nameLabel">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="nameEdit"/>
</item>
<item row="1" column="0">
<widget class="QLabel" name="typeLabel">
<property name="text">
<string>Port type:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="typeCombo">
<item>
<property name="text">
<string>Signal</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="orientationLabel">
<property name="text">
<string>Orientation:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="orientationCombo">
<item>
<property name="text">
<string>Input</string>
</property>
</item>
<item>
<property name="text">
<string>Output</string>
</property>
</item>
<item>
<property name="text">
<string>Indifferent</string>
</property>
</item>
</widget>
</item>
<item row="3" column="0" colspan="2">
<widget class="QCheckBox" name="multipleConnectionsCheckBox">
<property name="text">
<string>Allow multiple connections</string>
</property>
</widget>
</item>
<item row="5" column="0" colspan="2">
<widget class="QLabel" name="positionHintLabel">
<property name="text">
<string>New ports start at (0, 0) in the icon editor.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
<item row="4" column="0" colspan="2">
<widget class="QStackedWidget" name="typeOptionsStack">
<widget class="QWidget" name="signalOptionsPage">
<layout class="QFormLayout" name="signalOptionsForm">
<item row="0" column="0">
<widget class="QLabel" name="valueTypeLabel">
<property name="text">
<string>Type:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="valueTypeCombo">
<property name="editable">
<bool>true</bool>
</property>
<item>
<property name="text">
<string>real</string>
</property>
</item>
<item>
<property name="text">
<string>integer</string>
</property>
</item>
<item>
<property name="text">
<string>boolean</string>
</property>
</item>
<item>
<property name="text">
<string>string</string>
</property>
</item>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="quantityLabel">
<property name="text">
<string>Quantity:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QComboBox" name="quantityCombo">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="unitCombo">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="unitLabel">
<property name="text">
<string>Unit:</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="rowsColumnsLabel">
<property name="text">
<string>Matrix size:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<layout class="QHBoxLayout" name="horizontalLayout">
<item>
<widget class="QSpinBox" name="rowsSpin">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>9999</number>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="columnsSpin">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>9999</number>
</property>
</widget>
</item>
</layout>
</item>
<item row="4" column="0">
<widget class="QLabel" name="descriptionLabel">
<property name="text">
<string>Description:</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QPlainTextEdit" name="descriptionEdit"/>
</item>
</layout>
</widget>
<widget class="QWidget" name="powerOptionsPage">
<layout class="QFormLayout" name="powerOptionsForm">
<item row="0" column="0"><widget class="QLabel" name="domainLabel"><property name="text"><string>Domain:</string></property></widget></item>
<item row="0" column="1"><widget class="QComboBox" name="domainCombo"/></item>
<item row="1" column="0"><widget class="QLabel" name="effortLabel"><property name="text"><string>Effort:</string></property></widget></item>
<item row="1" column="1"><widget class="QLabel" name="effortValueLabel"><property name="text"><string>p.e</string></property></widget></item>
<item row="2" column="0"><widget class="QLabel" name="flowLabel"><property name="text"><string>Flow:</string></property></widget></item>
<item row="2" column="1"><widget class="QLabel" name="flowValueLabel"><property name="text"><string>p.f</string></property></widget></item>
<item row="3" column="0"><widget class="QLabel" name="causalityLabel"><property name="text"><string>Causality:</string></property></widget></item>
<item row="3" column="1"><widget class="QComboBox" name="causalityCombo"/></item>
<item row="4" column="0"><widget class="QLabel" name="powerDescriptionLabel"><property name="text"><string>Description:</string></property></widget></item>
<item row="4" column="1"><widget class="QPlainTextEdit" name="powerDescriptionEdit"/></item>
</layout>
</widget>
<widget class="QWidget" name="unsupportedTypePage">
<layout class="QVBoxLayout" name="unsupportedTypeLayout">
<item>
<widget class="QLabel" name="unsupportedTypeLabel">
<property name="text">
<string>This port type does not have an options editor yet.</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
</layout>
</widget>
</widget>
</item>
<item><widget class="QDialogButtonBox" name="buttonBox"><property name="standardButtons"><set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set></property></widget></item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection><sender>buttonBox</sender><signal>accepted()</signal><receiver>PortOptionsDialog</receiver><slot>accept()</slot><hints/></connection>
<connection><sender>buttonBox</sender><signal>rejected()</signal><receiver>PortOptionsDialog</receiver><slot>reject()</slot><hints/></connection>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>PortOptionsDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>20</x>
<y>20</y>
</hint>
<hint type="destinationlabel">
<x>20</x>
<y>20</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>PortOptionsDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>20</x>
<y>20</y>
</hint>
<hint type="destinationlabel">
<x>20</x>
<y>20</y>
</hint>
</hints>
</connection>
</connections>
</ui>

View File

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

View File

@@ -20,12 +20,12 @@
<widget class="QWidget" name="centralWidget">
<layout class="QVBoxLayout" name="resultsLayout">
<item>
<widget class="QLabel" name="resultsPlaceholder">
<property name="text">
<string>Simulation graphs and result controls can be added here.</string>
<widget class="QTabWidget" name="graphTabs">
<property name="tabsClosable">
<bool>false</bool>
</property>
<property name="alignment">
<set>Qt::AlignmentFlag::AlignCenter</set>
<property name="movable">
<bool>true</bool>
</property>
</widget>
</item>
@@ -76,10 +76,34 @@
<addaction name="actionAbout"/>
<addaction name="actionAboutQt"/>
</widget>
<widget class="QMenu" name="menuGraph">
<property name="title">
<string>&amp;Graph</string>
</property>
<addaction name="actionAddGraph"/>
<addaction name="actionRemoveGraph"/>
</widget>
<addaction name="menuFile"/>
<addaction name="menuView"/>
<addaction name="menuGraph"/>
<addaction name="menuHelp"/>
</widget>
<widget class="QToolBar" name="workspaceToolbar">
<property name="windowTitle">
<string>Workspace</string>
</property>
<property name="toolButtonStyle">
<enum>Qt::ToolButtonStyle::ToolButtonIconOnly</enum>
</property>
<attribute name="toolBarArea">
<enum>TopToolBarArea</enum>
</attribute>
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionAddGraph"/>
<addaction name="actionRemoveGraph"/>
</widget>
<widget class="QToolBar" name="fileToolbar">
<property name="windowTitle">
<string>File</string>
@@ -93,10 +117,10 @@
<attribute name="toolBarBreak">
<bool>false</bool>
</attribute>
<addaction name="actionClear"/>
<addaction name="actionOpen"/>
<addaction name="actionSave"/>
<addaction name="actionSaveAs"/>
<addaction name="actionClear"/>
</widget>
<widget class="QDockWidget" name="statusDock">
<property name="windowTitle">
@@ -162,6 +186,36 @@
</layout>
</widget>
</widget>
<widget class="QDockWidget" name="signalsDock">
<property name="windowTitle">
<string>Signals</string>
</property>
<attribute name="dockWidgetArea">
<number>2</number>
</attribute>
<widget class="QWidget" name="signalsDockContents">
<layout class="QVBoxLayout" name="signalsLayout">
<item>
<widget class="QTreeWidget" name="signalsTree">
<property name="contextMenuPolicy">
<enum>Qt::ContextMenuPolicy::CustomContextMenu</enum>
</property>
<property name="selectionMode">
<enum>QAbstractItemView::SelectionMode::ExtendedSelection</enum>
</property>
<property name="headerHidden">
<bool>true</bool>
</property>
<column>
<property name="text">
<string>Signal</string>
</property>
</column>
</widget>
</item>
</layout>
</widget>
</widget>
<action name="actionOpen">
<property name="icon">
<iconset resource="../resources/resources.qrc">
@@ -236,6 +290,30 @@
<string>Results</string>
</property>
</action>
<action name="actionAddGraph">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/list-add.png</normaloff>:/icons/icons/list-add.png</iconset>
</property>
<property name="text">
<string>Add Graph</string>
</property>
<property name="statusTip">
<string>Add a graph workspace tab</string>
</property>
</action>
<action name="actionRemoveGraph">
<property name="icon">
<iconset resource="../resources/resources.qrc">
<normaloff>:/icons/icons/list-remove.png</normaloff>:/icons/icons/list-remove.png</iconset>
</property>
<property name="text">
<string>Remove Current Graph</string>
</property>
<property name="statusTip">
<string>Remove the current graph workspace tab</string>
</property>
</action>
</widget>
<resources>
<include location="../resources/resources.qrc"/>

View File

@@ -12,11 +12,27 @@
<widget class="QSplitter" name="columnSplitter">
<property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property>
<property name="childrenCollapsible"><bool>false</bool></property>
<widget class="QGroupBox" name="equationsGroup">
<property name="title"><string>Equations</string></property>
<layout class="QVBoxLayout" name="equationsLayout">
<item><widget class="OpenModelicaEditor" name="equationsEdit"/></item>
</layout>
<widget class="QSplitter" name="sourceSplitter">
<property name="orientation"><enum>Qt::Orientation::Vertical</enum></property>
<property name="childrenCollapsible"><bool>false</bool></property>
<widget class="QGroupBox" name="declarationsGroup">
<property name="title"><string>Declarations</string></property>
<layout class="QVBoxLayout" name="declarationsLayout">
<item><widget class="OpenModelicaEditor" name="declarationsEdit"/></item>
</layout>
</widget>
<widget class="QGroupBox" name="initialEquationsGroup">
<property name="title"><string>Initial Equations</string></property>
<layout class="QVBoxLayout" name="initialEquationsLayout">
<item><widget class="OpenModelicaEditor" name="initialEquationsEdit"/></item>
</layout>
</widget>
<widget class="QGroupBox" name="equationsGroup">
<property name="title"><string>Equations</string></property>
<layout class="QVBoxLayout" name="equationsLayout">
<item><widget class="OpenModelicaEditor" name="equationsEdit"/></item>
</layout>
</widget>
</widget>
<widget class="QSplitter" name="definitionSplitter">
<property name="orientation"><enum>Qt::Orientation::Vertical</enum></property>
@@ -24,15 +40,13 @@
<widget class="QGroupBox" name="portsGroup">
<property name="title"><string>Ports</string></property>
<layout class="QVBoxLayout" name="portsLayout">
<item><widget class="QTableWidget" name="portsTable"><property name="selectionBehavior"><enum>QAbstractItemView::SelectionBehavior::SelectRows</enum></property><property name="selectionMode"><enum>QAbstractItemView::SelectionMode::SingleSelection</enum></property><property name="columnCount"><number>4</number></property><column><property name="text"><string>Name</string></property></column><column><property name="text"><string>Type</string></property></column><column><property name="text"><string>Orientation</string></property></column><column><property name="text"><string>Multiple</string></property></column></widget></item>
<item><layout class="QHBoxLayout" name="portButtonsLayout"><item><widget class="QPushButton" name="addPortButton"><property name="text"><string>Add Port</string></property></widget></item><item><widget class="QPushButton" name="removePortButton"><property name="text"><string>Remove Port</string></property></widget></item><item><spacer name="portButtonSpacer"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item></layout></item>
<item><widget class="PortEditor" name="portEditor"/></item>
</layout>
</widget>
<widget class="QGroupBox" name="parametersGroup">
<property name="title"><string>Parameters</string></property>
<layout class="QVBoxLayout" name="parametersLayout">
<item><widget class="QTableWidget" name="parametersTable"><property name="selectionBehavior"><enum>QAbstractItemView::SelectionBehavior::SelectRows</enum></property><property name="selectionMode"><enum>QAbstractItemView::SelectionMode::SingleSelection</enum></property><property name="columnCount"><number>3</number></property><column><property name="text"><string>Name</string></property></column><column><property name="text"><string>Type</string></property></column><column><property name="text"><string>Value</string></property></column></widget></item>
<item><layout class="QHBoxLayout" name="parameterButtonsLayout"><item><widget class="QPushButton" name="addParameterButton"><property name="text"><string>Add Parameter</string></property></widget></item><item><widget class="QPushButton" name="removeParameterButton"><property name="text"><string>Remove Parameter</string></property></widget></item><item><spacer name="parameterButtonSpacer"><property name="orientation"><enum>Qt::Orientation::Horizontal</enum></property><property name="sizeHint" stdset="0"><size><width>40</width><height>20</height></size></property></spacer></item></layout></item>
<item><widget class="ParameterEditor" name="parameterEditor"/></item>
</layout>
</widget>
</widget>
@@ -46,6 +60,8 @@
<extends>QPlainTextEdit</extends>
<header>bedit.gui.editors.openmodelica</header>
</customwidget>
<customwidget><class>PortEditor</class><extends>QWidget</extends><header>bedit.gui.dialogs.port_options</header></customwidget>
<customwidget><class>ParameterEditor</class><extends>QWidget</extends><header>bedit.gui.dialogs.parameter_options</header></customwidget>
</customwidgets>
<resources/>
<connections/>

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@ model m_Test
y = v;
end m_Constant0;
model m_gain0
input Real u;
input Real u(quantity="Velocity", unit="m/s");
output Real y;
parameter Real k = 2.5;
equation
@@ -16,7 +16,7 @@ model m_Test
output Real y;
parameter Real v = 4.8;
equation
y = v+sin(time);
y = v+sin(10*time);
end m_const_and_time;
model m_gain1
input Real u;
@@ -31,14 +31,43 @@ model m_Test
equation
y = sum(u[i] for i in 1:2 );
end m_add0;
model m_Integrate0
input Real u;
output Real y;
parameter Real y_start = 0;
initial equation
y = y_start;
equation
der(y) = u;
end m_Integrate0;
model m_Differentiate0
input Real u;
output Real y;
parameter Real x_start = 0;
parameter Real y_start = 0;
parameter Real k = 1;
parameter Real T = 0.01;
Real x(start=x_start);
initial equation
y = y_start;
equation
assert(T > 0, "Differentiate time constant T must be positive");
der(x) = (u - x)/T;
y = (k/T)*(u - x);
end m_Differentiate0;
m_Constant0 Constant0;
m_gain0 gain0;
m_const_and_time const_and_time;
m_gain1 gain1;
m_add0 add0;
m_Integrate0 Integrate0;
m_Differentiate0 Differentiate0;
equation
gain0.u = Constant0.y;
gain1.u = const_and_time.y;
add0.u[1] = gain0.y;
add0.u[2] = gain1.y;
end m_Test;
Integrate0.u = add0.y;
Differentiate0.u = add0.y;
end m_Test;