From edd7bb98f26a9f4d3044b1dc54ad5d60df6ba860 Mon Sep 17 00:00:00 2001 From: Joppe Blondel Date: Tue, 21 Jul 2026 14:40:03 +0200 Subject: [PATCH] Saving sim results --- BEdit/.vscode/tasks.json | 1 - BEdit/AGENTS.md | 12 +- BEdit/m_Test-results.json | 27 -- BEdit/resources/icons/office-chart-line.png | Bin 0 -> 877 bytes BEdit/resources/icons/view-form-table.png | Bin 0 -> 429 bytes BEdit/resources/resources.qrc | 2 + BEdit/resources/resources_rc.py | 108 +++++++- BEdit/src/bedit/core/simulation/__init__.py | 13 +- .../src/bedit/core/simulation/openmodelica.py | 21 +- BEdit/src/bedit/core/simulation/results.py | 143 ++++++++++- BEdit/src/bedit/data/libraries/default.beb | Bin 435 -> 880 bytes BEdit/src/bedit/gui/controllers/commands.py | 2 +- BEdit/src/bedit/gui/controllers/document.py | 62 +++++ BEdit/src/bedit/gui/generated/resources_rc.py | 108 +++++++- .../src/bedit/gui/generated/ui_main_window.py | 128 +++++----- .../gui/generated/ui_simulation_window.py | 96 ++++---- BEdit/src/bedit/gui/graphics/workspace.py | 16 ++ BEdit/src/bedit/gui/main_window.py | 176 ++++++++++++- BEdit/src/bedit/gui/simulation_window.py | 63 +++-- BEdit/ui/main_window.ui | 4 +- BEdit/ui/simulation_window.ui | 231 +++++++++++++++--- 21 files changed, 987 insertions(+), 226 deletions(-) delete mode 100644 BEdit/m_Test-results.json create mode 100644 BEdit/resources/icons/office-chart-line.png create mode 100644 BEdit/resources/icons/view-form-table.png diff --git a/BEdit/.vscode/tasks.json b/BEdit/.vscode/tasks.json index 7fe5a22..a5b5d1b 100644 --- a/BEdit/.vscode/tasks.json +++ b/BEdit/.vscode/tasks.json @@ -6,7 +6,6 @@ "type": "shell", "command": "pyside6-designer", "args": [ - "${workspaceFolder}/ui/*.ui" ], "options": { "cwd": "${workspaceFolder}", diff --git a/BEdit/AGENTS.md b/BEdit/AGENTS.md index b7fdd17..4376fd9 100644 --- a/BEdit/AGENTS.md +++ b/BEdit/AGENTS.md @@ -94,6 +94,11 @@ Keep the root of `src/bedit` minimal. New UI modules should go into the relevant - Avoid the QSettings group name `general`; Qt treats `General` specially in INI files. Autosave keys live under `autosave/`. - User-visible document edits should participate in undo/redo. +- Component clipboard data is shared by the graph, document tree, and library + tree. Pasting into the document node creates roots; pasting into a graph node + creates children at an origin-normalized position. Always clone pasted trees + with fresh IDs, preserve connections between jointly copied graph blocks, and + assign unique sibling names. - The text-definition editor uses OpenModelica highlighting and completion from `src/bedit/data/syntax/openmodelica.json`. Keep keywords, types, built-ins, and named BEdit `$name$` macro completions editable there; arbitrary `$name$` @@ -126,9 +131,14 @@ 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. -- Standalone simulation-result JSON is modeled in `core/simulation/results.py`. +- 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. + Human-readable `.json` uses JSON, while the default `.ber` format uses the same + compressed MessagePack approach as `.beb` documents. +- After a successful OpenModelica run, `_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 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 diff --git a/BEdit/m_Test-results.json b/BEdit/m_Test-results.json deleted file mode 100644 index d2631c4..0000000 --- a/BEdit/m_Test-results.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "format": "bedit-simulation-results", - "version": 1, - "modelName": "m_Test", - "status": { - "phase": "Simulation finished", - "currentStepSize": 0.0, - "time": 10.0, - "progress": 10000 - }, - "messages": [ - { - "stream": "LOG_SUCCESS", - "type": "info", - "text": "The initialization finished successfully without homotopy method." - }, - { - "stream": "LOG_SUCCESS", - "type": "info", - "text": "The simulation finished successfully." - } - ], - "traces": [], - "metadata": { - "processResult": 0 - } -} diff --git a/BEdit/resources/icons/office-chart-line.png b/BEdit/resources/icons/office-chart-line.png new file mode 100644 index 0000000000000000000000000000000000000000..816b2c5dd4dd292642241327f5c893190d094678 GIT binary patch literal 877 zcmV-z1CsoSP)Px#24YJ`L;(K){{a7>y{D4^000SaNLh0L02{9W02{9XUK)`c00007bV*G`2iXD; z7a|s!Go?HL00QkvL_t(|+U=KLNYhai$IsdCH_OV2LLiY3$;^6aVfLuB22GG=VQE$* zmR1t^P%TrCWF<^!Rxd&>jl#0P9&%H`gdAjVVW1B_Xb=5)@GYCB53zguZuE`a7ThZApr#8nond2Iulj`&DH}*gsB9p zudnZf%Z~@(de4GERmi65c>MO!jblq@prxgy3T`A;(CWwYhMKPV)3x36uY{z}Qc|g0 zCId0E^7bFOP}>xc(yU1K0ooG~RMA?N?5B7KP|`NXP}?B{y%0!iQjgS=6VnL3w6(9 zs2{wNBL8s|M{Yu-+d@)#&r*5Iq*!fJ&!H?&EKUBX7KbMk<$FufGDUx0z8?NOb z0|vKcAf>~!(f|NIk%wA`QVw5eo9dnpKi}Q>odZJN40b-6@fu6LwSQ}~buqZzFkpD- zM)@A$IqHf7V5$2F_$h!LwvW#O+J2^ehIZVTF$M%R0!~7Z_waPH9~roSs2=E@w_C3eb~D@(VY6> z4>0|nI|LIO>_qGW+)9$q13VP<-aJiMwwwD2Hp3=S(gE1Iny{jc8%F>C7n z{`lcY-vv_2d}bbr(Vk|#Xm}4rG;K_;VlP4l5^ui%qZe0MT5;J|00000NkvXXu0mjf D1ptdC literal 0 HcmV?d00001 diff --git a/BEdit/resources/icons/view-form-table.png b/BEdit/resources/icons/view-form-table.png new file mode 100644 index 0000000000000000000000000000000000000000..40c084e297318605fca9708ca839abdb09db2a54 GIT binary patch literal 429 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7dyjKx9jP7LeL$-D$|I14-?i-C%- zgD|6$#_S59AbW|YuPgga9vNmCKGk5IB%n}ffKP}kkQNpe=HlYw=H>>n(ZI1|CypOK zdGh4xGiT17J9pv2g-chiT)Te##`WtrZ``M_wL<)^yu;9 z$4?(We)iY&NCLT<@AR{5g^Jd<` zqyY7KCDYGMy&^Hiwov)5XLwL&qa3qq({xXNrWxH$?u_%^^mb)^Y&pM2F`RiqFH?R{ zoNnAl`|1bHC!}l>&K-N5vTSnq5v_YhM_%jKoK=6b-+%uEUvR`Qc)I$ztaD0e0st^9 BwYvZS literal 0 HcmV?d00001 diff --git a/BEdit/resources/resources.qrc b/BEdit/resources/resources.qrc index 3bc7bb5..1806ce0 100644 --- a/BEdit/resources/resources.qrc +++ b/BEdit/resources/resources.qrc @@ -1,5 +1,7 @@ + icons/view-form-table.png + icons/office-chart-line.png icons/run-build.png icons/preferences-system.png icons/draw-triangle.png diff --git a/BEdit/resources/resources_rc.py b/BEdit/resources/resources_rc.py index 311a8cf..e3fafc1 100644 --- a/BEdit/resources/resources_rc.py +++ b/BEdit/resources/resources_rc.py @@ -2326,6 +2326,63 @@ K\x80@\x89\x15\x8d\xc04\xd5\xb5^\xaf\x1bx\xfa\x19\ \x84\xe7\x04\xcf\x88\xfd\xfd\xfd\xe4\xe8\xe8\xc8\x9dL&\x14\ \xc7\xcc\x7f5p\xf2g\x94\xf7\x1f\xdf\x9a\xd2\x93\xfbC\ \xb7\xa7\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x03m\ +\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\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\ +\x00\x00\x09pHYs\x00\x00\x1b\xaf\x00\x00\x1b\xaf\x01\ +^\x1a\x91\x1c\x00\x00\x00\x07tIME\x07\xd9\x02\x10\ +\x17\x22\x16\x993\xa5<\x00\x00\x02\xedIDATx\ +\xda\xed\x97_H\xd3Q\x14\xc7\xcf\xd9\xef7\xcb\xca\x89\ +B \x91\x0f\xc9\xcczha\xf6\xa8\xb4\x06M\x90f\ +aiV$\x96V\x12\xf9P-S\x90d%Lh\ +V/B.\x8d\xc2\xb2\xc0\x1erS\xc1\x84\x1cdo\ +a\xa0\x0f>h\x0f\xfdy\xf0-\x9b\xa6\x0f\xb1{\xfb\ +n\xf4\x8b\x91\xe2\x5cn\xae\x07\x0f\xdc\x9dq/\xfc>\ +\xe7{\xce\xe1\xdc\xdf\x8f\xd6M\xb3\x86\x86\x86\xe2Dp\ +u\xbf\xe19pG\x82>!\x01\x04\xe1\x9aOd\x09\ +:\x13V\x02\xcd\xd6\x03H\x84\xa9\x04\xab\xaf\xaf\xef\x84\ +\xcb\x8f\x07\xe0z\xcf\x82AU\xc8\xa6\xeax\xfc\xf6\xd1\ +\x8dcKf\xa0\xa5\xa5\xa5\x0an$V\xd0\xea\xc7\xf3\ +\x86\x9a\xae\xf9\xd3\xb5\xdd\xf3\xaf\x84\xa4\xcfRR\xa9\x5c\ +&\x031\xb3\xf2\xf6\x1f\xb9P\xdb\x14\x90\xd2\xacH\xf6\ +\x01\xda\x13\x10T\xd1Z\x96\xec\xa7x\x07P\xd26\xc7\ +P\xdb!\x04\xbd\x10L\x15\xedg7-\x82\xc65\x00\ +\xc0m\x02?\x82\xd9\xf5\xa4j\xb3\x5c\xd3&,\xba\x1f\ +R\x8f\xd4\x93\xe3\xe5\xc5\x08\xf0X7\xa1\xa6> B\ +\xde\xbb&s\xc0\xb3\xf3x\x81'\xbbt\x1f\xc1,w\ +f\x19M\xd7$\xa0\xbe\xf7\xf2\x16\x19\xf5\x1c\x88\x0e|\ +L!\xc9\x8d$u\x97X\xa8\x0b\xde\xac\x0aS\xab$\ +3\x07\xd5\xb3\xd4\xd4\xc7'\x00\xa8\xce\x00\xfc)\xe0\xcc\ +\xa4\xdbK2\xe9\x96\x94\xaa\x0bu\xcfc\xa8\x1f\xbc\x92\ +\x22\xffq\x14GnB\xa4\xdbBRy\xcfRy\xcb\ +\xa4XmS\xcf\xa7I\xe8\xed,\xf4\x85\xbb&\xc7\xb4\ +\xda\xc7\xfe:\x06X\xf1d\x975y\x8c\xe5_\xbd\xc6\ +\x93\x96\xf0\xb3\x5c\x87\x9f++\xdf\x8d\xf6m\xbf\xfa\xa9\ +o[\x9d!\xe6\x97\x11\xc0\x198\x1e\x84\xea\x02\xa4|\ +?T\xbf\x09?\x97\x92\x8a\xc7\xb3\xf6H)\x92^#\ +\x1b\xae\xe5 \x03\x06\xb7e \xa5\xc3\xb4\xd2\x00\x00?\ +\x91\x87Z\x87R\x0e_h\x9b\xea\x9e\x0e?\xdf\xdd\xf8\ +\x9d\x03B\xde\x0cv>\x9a\xf1\x1aK\xbd\xb5\x7fk\xb3\ +u1\xb8\xdd0`x\xe8F\xf9\x1e\xe19\xea\x8a\x03\ +`\xa9\xfb\x09\xf8)\x80\x1d\xb6\x8f\xcf\x02\xda~\xa6}\ +\x86v\xdc\x981\x06\x045\x02NB\x90\xf7\xf0t\xb3\ +\x1f\x19\xb8\x80\xe5\xeeOo5\x84\xa9>\x040\x1aD\ +!\xc0MEs\xe7F\x97diM\xc8\xcc\xf9N\xa7\ +3'\xfc0\xb9\xe6\x1b\xe9\x98\x8c\xaaB\x07\x92\x142\ +\xebU6\xeb\xf1\xbc$\x85}\xd8\xbb;\xd1\x9c\xfa\xe1\ +\x0f0\xfd\x9e;\x04\x13\x1b\xecD\xec\x02\xdcJ\x92\xcf\ +\x03<\x14\xf5$\x04\xd8\x84\xd5\x85\xbf_P\xeba,\ +3\x96\x0f\x8a\x0fB}\xe6\xa43\xf5L\x08\x1enB\ +\xb5\x13J\x01\xb5\x13a\xaa\x87V3\x07|X\x0eI\ +4\xe5oK\x8b|\x1f\xcc\xd4\xfa\xd1h%\x00\xa7\x02\ +<\xbc\xaaA\xb4\xf0 m\x0c\x0e+:+\x9a\xad\x1e\ +\xfd\xaf\xdf\x091k\xea\xfe\xfe\xf8\xe1H\xdf\x05R\xca\ +|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\x04<\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -2396,6 +2453,35 @@ f\xa2\xd1\xa5\x5c\x22\xb4\x91SZ\xd5u\xd7\x0a\xd2]\ |\x01\x85\x09\x800\x7fss\xd3{\xf6\x7fABG\ Y\x01\x05l*\xfc\x00!\x00\x12\xf1%U\xb6\x0e\x00\ \x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x01\xad\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x03\x00\x00\x00D\xa4\x8a\xc6\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x09pHYs\x00\x00\x0d\xd7\x00\x00\x0d\xd7\x01B(\ +\x9bx\x00\x00\x00\x07tIME\x07\xd9\x0c\x1c\x03\x1c\ +\x0e%S,b\x00\x00\x00uPLTE\x00\x00\x00\ +\x13\x13\x13\x0a\x0a\x0a\x0b\x0b\x0b\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc6\xc6\xc8\xc7\xc7\ +\xc9\xc9\xc9\xcb\xcc\xcc\xce\xce\xce\xd0\xd0\xd0\xd2\xd4\xd4\xd6\ +\xd7\xd7\xd8\xd7\xd7\xd9\xd8\xd8\xda\xd9\xd9\xdb\xda\xda\xdb\xda\ +\xda\xdc\xdb\xdb\xdc\xdb\xdb\xdd\xdd\xdd\xdf\xde\xde\xdf\xe2\xe2\ +\xe3\xe3\xe3\xe5\xe3\xe3\xe6\xe4\xe4\xe6\xe6\xe6\xe7\xe7\xe7\xe9\ +\xe9\xe9\xea\xeb\xeb\xec\xed\xed\xee\xf0\xf0\xf1\xf3\xf3\xf4\xff\ +\xff\xff\xd3\x9b\xcc\x0e\x00\x00\x00\x0atRNS\x00\x09\ +\x15\x15\x1825678\xb5\xcc\xc0\x1e\x00\x00\x00\x01\ +bKGD&Z\x08\x98\xb5\x00\x00\x00\x9bIDA\ +Tx\xda\xd5\x93\xcb\x0e\x820\x10\x00\x8b\x0aEP|\ +u\xc1G)\x94\x02\xff\xff\x89.]\x0e\x18\xccr1\ +F\xe72\xd9d\xd26\x9bT\xfc\x02\xc1\x86%\x10a\ +\xcf\x12\x0a\xd9)\x86N\x0a\xd9^\x18Z\x0c\xdc\x91\xc1\ +a\xd0\x1c\x18\x1a\x0c\xec\x9e\xc1bP'\x9et\x97\xce\ +\x95\xd4\x18\x94=q#\xddIWR\x89\x81\x1e\x03E\ +\x82\x97IO\x02\x98\x8b\x82G\x01\x9e\xec\x8d\x8aj\xf1\ +\x84\xcf\xbc!W\x03\x90\x8d\x02oR^-^\xf1?\ +{\xe0\x83\xc8\x1a=`\xce\xc6\xebd\xa6\x93\x8b\xc4*\ +\xde2\xc4\xeb/|\xcd'\xec\xbfO\xbf\x90M\x1a\x0a\ +\x00\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x02\x92\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -2632,10 +2718,20 @@ qt_resource_name = b"\ \x00d\ \x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00.\x00p\x00n\x00g\ \ +\x00\x15\ +\x02\xb4\x1f\x07\ +\x00o\ +\x00f\x00f\x00i\x00c\x00e\x00-\x00c\x00h\x00a\x00r\x00t\x00-\x00l\x00i\x00n\x00e\ +\x00.\x00p\x00n\x00g\ \x00\x10\ \x03\xe6\xd3g\ \x00d\ \x00r\x00a\x00w\x00-\x00e\x00l\x00l\x00i\x00p\x00s\x00e\x00.\x00p\x00n\x00g\ +\x00\x13\ +\x07\xd6O\x07\ +\x00v\ +\x00i\x00e\x00w\x00-\x00f\x00o\x00r\x00m\x00-\x00t\x00a\x00b\x00l\x00e\x00.\x00p\ +\x00n\x00g\ \x00\x12\ \x09\xb3>\xc7\ \x00d\ @@ -2653,7 +2749,7 @@ 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\x1c\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x1e\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\x01\x9f{C\xf1'\ @@ -2661,11 +2757,13 @@ qt_resource_struct = b"\ \x00\x00\x01\x9f\x7f\xa8\xa8)\ \x00\x00\x02\xec\x00\x00\x00\x00\x00\x01\x00\x00tV\ \x00\x00\x01\x9f{0\xc99\ +\x00\x00\x03\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x8d\xaa\ +\x00\x00\x01\x9f\x84\x8f\x00\xb9\ \x00\x00\x01\xc4\x00\x00\x00\x00\x00\x01\x00\x00D2\ \x00\x00\x01\x9f\x7f&\x83\xcd\ \x00\x00\x01\xa4\x00\x00\x00\x00\x00\x01\x00\x00 int: +) -> SimulationExecutionResult: executable_path = Path(executable) executable_command = executable if not executable_path.is_absolute() and executable_path.parent == Path("."): @@ -324,7 +328,20 @@ def _run_model_with_tcp( raise RuntimeError( f"Simulation process exited with status {return_code}{detail}" ) - return int(return_code) + result_path = temp_dir / f"{Path(executable).stem}_res.csv" + if not result_path.is_file(): + raise RuntimeError( + f"OpenModelica did not create the expected result file {result_path.name!r}" + ) + data = load_openmodelica_csv(result_path) + log.info( + "Loaded %d result columns from %s", len(data), result_path.name + ) + return SimulationExecutionResult( + return_code=int(return_code), + result_file=str(result_path), + data=data, + ) def _accept_simulation_connection( diff --git a/BEdit/src/bedit/core/simulation/results.py b/BEdit/src/bedit/core/simulation/results.py index 3f5f1f8..5d4e969 100644 --- a/BEdit/src/bedit/core/simulation/results.py +++ b/BEdit/src/bedit/core/simulation/results.py @@ -1,8 +1,12 @@ +import csv import json +import zlib from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any +import msgpack + RESULTS_FORMAT = "bedit-simulation-results" RESULTS_VERSION = 1 @@ -28,6 +32,7 @@ class SimulationResults: model_name: str = "" 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) metadata: dict[str, Any] = field(default_factory=dict) @@ -38,6 +43,7 @@ class SimulationResults: "modelName": self.model_name, "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], "metadata": dict(self.metadata), } @@ -54,6 +60,10 @@ class SimulationResults: model_name=str(data.get("modelName", "")), status=dict(data.get("status", {})), messages=[dict(message) for message in data.get("messages", [])], + data={ + str(name): [float(value) for value in values] + for name, values in dict(data.get("data", {})).items() + }, traces=traces, metadata=dict(data.get("metadata", {})), ) @@ -61,18 +71,129 @@ class SimulationResults: raise ValueError("Malformed simulation-results data") from error +@dataclass(frozen=True) +class SimulationExecutionResult: + """Completed process information delivered before its temp files disappear.""" + + return_code: int + result_file: str + data: dict[str, list[float]] + + +def load_openmodelica_csv(path: str | Path) -> dict[str, list[float]]: + """Read an OpenModelica CSV result as one numeric array per column.""" + + source = Path(path) + try: + with source.open(newline="", encoding="utf-8") as file: + reader = csv.reader(file) + headers = next(reader) + if not headers or any(not header for header in headers): + raise ValueError("The result CSV has an invalid header") + if len(set(headers)) != len(headers): + raise ValueError("The result CSV contains duplicate column names") + columns = {header: [] for header in headers} + for row_number, row in enumerate(reader, start=2): + if len(row) != len(headers): + raise ValueError( + f"Result CSV row {row_number} has {len(row)} values; " + f"expected {len(headers)}" + ) + for header, value in zip(headers, row, strict=True): + columns[header].append(float(value)) + except OSError as error: + raise ValueError(f"Could not read OpenModelica results: {error}") from error + except StopIteration as error: + raise ValueError("The OpenModelica result CSV is empty") from error + except ValueError as error: + if str(error).startswith(("The result CSV", "Result CSV")): + raise + raise ValueError(f"The OpenModelica result CSV is not numeric: {error}") from error + return columns + + +class JsonSimulationResultsSerializer: + @staticmethod + def load(path: Path) -> SimulationResults: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"Could not read simulation results: {error}") from error + if not isinstance(data, dict): + raise ValueError("Simulation-results root must be an object") + return SimulationResults.from_dict(data) + + @staticmethod + def save(results: SimulationResults, path: Path) -> None: + temporary_path = path.with_suffix(path.suffix + ".tmp") + temporary_path.write_text( + json.dumps(results.to_dict(), indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + temporary_path.replace(path) + + +class BerSimulationResultsSerializer: + """Compressed MessagePack serializer for binary simulation results.""" + + MAGIC = b"BER\x00" + VERSION = 1 + + @classmethod + def load(cls, path: Path) -> SimulationResults: + try: + payload = path.read_bytes() + except OSError as error: + raise ValueError(f"Could not read simulation results: {error}") from error + header = cls.MAGIC + bytes([cls.VERSION]) + if not payload.startswith(header): + raise ValueError("This is not a supported BEdit binary results file") + try: + data = msgpack.unpackb( + zlib.decompress(payload[len(header) :]), raw=False + ) + except (ValueError, zlib.error, msgpack.exceptions.MsgpackException) as error: + raise ValueError("The BEdit binary results file is damaged") from error + if not isinstance(data, dict): + raise ValueError("The BEdit binary results file has an invalid root value") + return SimulationResults.from_dict(data) + + @classmethod + def save(cls, results: SimulationResults, path: Path) -> None: + packed = msgpack.packb(results.to_dict(), use_bin_type=True) + payload = cls.MAGIC + bytes([cls.VERSION]) + zlib.compress(packed, level=9) + temporary_path = path.with_suffix(path.suffix + ".tmp") + temporary_path.write_bytes(payload) + temporary_path.replace(path) + + +class SimulationResultsSerializer: + """Select JSON or compressed MessagePack based on the file extension.""" + + @staticmethod + def load(path: str | Path) -> SimulationResults: + target = Path(path) + serializer = ( + BerSimulationResultsSerializer + if target.suffix.lower() == ".ber" + else JsonSimulationResultsSerializer + ) + return serializer.load(target) + + @staticmethod + def save(results: SimulationResults, path: str | Path) -> None: + target = Path(path) + serializer = ( + BerSimulationResultsSerializer + if target.suffix.lower() == ".ber" + else JsonSimulationResultsSerializer + ) + serializer.save(results, target) + + def save_simulation_results(path: str | Path, results: SimulationResults) -> None: - Path(path).write_text( - json.dumps(results.to_dict(), indent=2, ensure_ascii=False) + "\n", - encoding="utf-8", - ) + SimulationResultsSerializer.save(results, path) def load_simulation_results(path: str | Path) -> SimulationResults: - try: - data = json.loads(Path(path).read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as error: - raise ValueError(f"Could not read simulation results: {error}") from error - if not isinstance(data, dict): - raise ValueError("Simulation-results root must be an object") - return SimulationResults.from_dict(data) + return SimulationResultsSerializer.load(path) diff --git a/BEdit/src/bedit/data/libraries/default.beb b/BEdit/src/bedit/data/libraries/default.beb index 6a1405d82111f65645324d76a049c46ade8d0b29..f3e2ce9f71b1a18cc4e70e6af3641ed56f8adc23 100644 GIT binary patch literal 880 zcmV-$1CRVdMM3}pc-qaD-Hy{n6vyd1^Z_if7X{=haqQSBs%peaT?q-TSVWO3)Q)G) zY)0$MxHIEr!`-eB5{L^v`T{A=$5M)_@&bsx%oFeyz3gSjj^kvr5nV`28B5VPo|*su z%$(n`?+&Pt9i(4IK$y0Pz-b4#9`1rvOz_V}9ngp%jrKE+I`I4DR7&&QS}zCor4U*@ zeeeq-Z`Z(UxDBrs*iMuBw(F9}j+{orCQ;xa{1g+}@F@tLw6GOj^VgyCSoc#>-MXed&hcOKs}E`CddG;t8z@*UqV)Oy`x zGSiie%(VMFks<-98L0gi3=6(FH$J>kjStr+-As2A7$~;QQT(ct#+oHDn3y==e3p9i zJVDYZ8>i~=UE}JH>fWw7PTlt@o><#0mdYfcxW9|y6najU8@Sh3ZIr;FG%F&$9YaQ0 z91qV@H@^mvkObuLY@?Q6nT9=`UC)0My8{RP+l=V;IQ((qcF+d4-PYCp2x7?3PW}Ag z1s0NnyoLx%)j0gT4DXoMboOR>05RjRrMofom53QB?!PMpzxk^aR9cE1nAz*jM>2I+ zu9xp{INRZ}d%y6IalxT7u<`x|YNq*gvd8(bQoBllz3NEkPIrlRm3^ygPs3QyzOj<_ zp9|WQB2=rLR(ta#1tZlKx38KEJsQR=l1O&&x34-aZ=>r zK#5dh*gu4y(&CvKbgi$g-HVeNfxvCn^YY--Xk@z{ZrKPosqOhulR6EAf_k$+Bl{LH ze!M<}A5H z`LyKQd`Doza%4LY7`|OfY~(qf4PNB>0mczU^&+93T_WUIwud)oc;EaWym71L1*W9@ zPk0|K!5cZWMgaXgcwe7c+IWAwAALkv?=Q!4Vz0_-1t;-,D(!rce$Q@M=JVcS7UNd^tF`opt83P|i+=6*CZp01&~ z2FI+hg%HXr;RFQD&jCf;fu&EtO;|ENJ0xA3s(#h=UcLTQL`bpV>j0!;M`D=9V7Pn_ zWEGnGRSYboV6n=Kh~d|>l&HoBlfE%0M79@a-P$WWF z7s@0l+w&4rZj-A-P|Z@~_B>M|n$NkNi3BzTAq#V&A&ZpOp$u`uM)xph!xn6rB(q6TCRTB87`)2zy$F7TE$7{ dSo@*tY_5!K2?~oTg|OPdyR^mZw|@=)^gb6e-2MOn diff --git a/BEdit/src/bedit/gui/controllers/commands.py b/BEdit/src/bedit/gui/controllers/commands.py index b1a36f4..29fc295 100644 --- a/BEdit/src/bedit/gui/controllers/commands.py +++ b/BEdit/src/bedit/gui/controllers/commands.py @@ -345,7 +345,7 @@ class PasteSelectionCommand(QUndoCommand): def __init__( self, controller, - owner_id: str, + owner_id: str | None, blocks: dict[str, Component], connections: dict[str, Connection], ) -> None: diff --git a/BEdit/src/bedit/gui/controllers/document.py b/BEdit/src/bedit/gui/controllers/document.py index fff9f07..6f6be93 100644 --- a/BEdit/src/bedit/gui/controllers/document.py +++ b/BEdit/src/bedit/gui/controllers/document.py @@ -963,6 +963,68 @@ class DocumentController(QObject): self.undo_stack.push(PasteSelectionCommand(self, owner.id, blocks, connections)) return list(blocks) + def paste_components_to( + self, + owner_id: str | None, + source_components: list[Component], + source_connections: list[Connection] | None = None, + ) -> list[str]: + """Clone components into the document root or a graph at origin.""" + + if self.document is None: + raise ValueError("Open or create a document before pasting components") + if owner_id is None: + siblings = self.document.roots.values() + else: + owner = self.document.find_component(owner_id) + if owner is None or owner.implementation_kind != "graph": + raise ValueError("Components can only be pasted into a graph") + siblings = owner.graph.blocks.values() + + pairs = [(source, clone_component(source)) for source in source_components] + if not pairs: + return [] + id_map = {source.id: clone.id for source, clone in pairs} + used = list(siblings) + minimum_x = min(source.x for source, _clone in pairs) + minimum_y = min(source.y for source, _clone in pairs) + blocks: dict[str, Component] = {} + for source, clone in pairs: + clone.name = self._available_component_name(source.name, used, 0) + if owner_id is not None: + clone.x = source.x - minimum_x + clone.y = source.y - minimum_y + blocks[clone.id] = clone + used.append(clone) + + connections: dict[str, Connection] = {} + if owner_id is not None: + for source in source_connections or []: + if source.source.block not in id_map or source.target.block not in id_map: + continue + properties = deepcopy(source.properties) + for point in properties.get("waypoints", []): + if isinstance(point, dict): + point["x"] = float(point.get("x", 0)) - minimum_x + point["y"] = float(point.get("y", 0)) - minimum_y + connection = Connection( + id=str(uuid4()), + source=Endpoint( + block=id_map[source.source.block], port=source.source.port + ), + target=Endpoint( + block=id_map[source.target.block], port=source.target.port + ), + name=source.name, + properties=properties, + ) + connections[connection.id] = connection + + self.undo_stack.push( + PasteSelectionCommand(self, owner_id, blocks, connections) + ) + return list(blocks) + def _graph_for(self, owner_id: str): if self.document is None: raise ValueError("There is no open document") diff --git a/BEdit/src/bedit/gui/generated/resources_rc.py b/BEdit/src/bedit/gui/generated/resources_rc.py index 311a8cf..e3fafc1 100644 --- a/BEdit/src/bedit/gui/generated/resources_rc.py +++ b/BEdit/src/bedit/gui/generated/resources_rc.py @@ -2326,6 +2326,63 @@ K\x80@\x89\x15\x8d\xc04\xd5\xb5^\xaf\x1bx\xfa\x19\ \x84\xe7\x04\xcf\x88\xfd\xfd\xfd\xe4\xe8\xe8\xc8\x9dL&\x14\ \xc7\xcc\x7f5p\xf2g\x94\xf7\x1f\xdf\x9a\xd2\x93\xfbC\ \xb7\xa7\x00\x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x03m\ +\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\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x06bKGD\x00\xff\x00\xff\x00\xff\xa0\xbd\xa7\x93\x00\ +\x00\x00\x09pHYs\x00\x00\x1b\xaf\x00\x00\x1b\xaf\x01\ +^\x1a\x91\x1c\x00\x00\x00\x07tIME\x07\xd9\x02\x10\ +\x17\x22\x16\x993\xa5<\x00\x00\x02\xedIDATx\ +\xda\xed\x97_H\xd3Q\x14\xc7\xcf\xd9\xef7\xcb\xca\x89\ +B \x91\x0f\xc9\xcczha\xf6\xa8\xb4\x06M\x90f\ +aiV$\x96V\x12\xf9P-S\x90d%Lh\ +V/B.\x8d\xc2\xb2\xc0\x1erS\xc1\x84\x1cdo\ +a\xa0\x0f>h\x0f\xfdy\xf0-\x9b\xa6\x0f\xb1{\xfb\ +n\xf4\x8b\x91\xe2\x5cn\xae\x07\x0f\xdc\x9dq/\xfc>\ +\xe7{\xce\xe1\xdc\xdf\x8f\xd6M\xb3\x86\x86\x86\xe2Dp\ +u\xbf\xe19pG\x82>!\x01\x04\xe1\x9aOd\x09\ +:\x13V\x02\xcd\xd6\x03H\x84\xa9\x04\xab\xaf\xaf\xef\x84\ +\xcb\x8f\x07\xe0z\xcf\x82AU\xc8\xa6\xeax\xfc\xf6\xd1\ +\x8dcKf\xa0\xa5\xa5\xa5\x0an$V\xd0\xea\xc7\xf3\ +\x86\x9a\xae\xf9\xd3\xb5\xdd\xf3\xaf\x84\xa4\xcfRR\xa9\x5c\ +&\x031\xb3\xf2\xf6\x1f\xb9P\xdb\x14\x90\xd2\xacH\xf6\ +\x01\xda\x13\x10T\xd1Z\x96\xec\xa7x\x07P\xd26\xc7\ +P\xdb!\x04\xbd\x10L\x15\xedg7-\x82\xc65\x00\ +\xc0m\x02?\x82\xd9\xf5\xa4j\xb3\x5c\xd3&,\xba\x1f\ +R\x8f\xd4\x93\xe3\xe5\xc5\x08\xf0X7\xa1\xa6> B\ +\xde\xbb&s\xc0\xb3\xf3x\x81'\xbbt\x1f\xc1,w\ +f\x19M\xd7$\xa0\xbe\xf7\xf2\x16\x19\xf5\x1c\x88\x0e|\ +L!\xc9\x8d$u\x97X\xa8\x0b\xde\xac\x0aS\xab$\ +3\x07\xd5\xb3\xd4\xd4\xc7'\x00\xa8\xce\x00\xfc)\xe0\xcc\ +\xa4\xdbK2\xe9\x96\x94\xaa\x0bu\xcfc\xa8\x1f\xbc\x92\ +\x22\xffq\x14GnB\xa4\xdbBRy\xcfRy\xcb\ +\xa4XmS\xcf\xa7I\xe8\xed,\xf4\x85\xbb&\xc7\xb4\ +\xda\xc7\xfe:\x06X\xf1d\x975y\x8c\xe5_\xbd\xc6\ +\x93\x96\xf0\xb3\x5c\x87\x9f++\xdf\x8d\xf6m\xbf\xfa\xa9\ +o[\x9d!\xe6\x97\x11\xc0\x198\x1e\x84\xea\x02\xa4|\ +?T\xbf\x09?\x97\x92\x8a\xc7\xb3\xf6H)\x92^#\ +\x1b\xae\xe5 \x03\x06\xb7e \xa5\xc3\xb4\xd2\x00\x00?\ +\x91\x87Z\x87R\x0e_h\x9b\xea\x9e\x0e?\xdf\xdd\xf8\ +\x9d\x03B\xde\x0cv>\x9a\xf1\x1aK\xbd\xb5\x7fk\xb3\ +u1\xb8\xdd0`x\xe8F\xf9\x1e\xe19\xea\x8a\x03\ +`\xa9\xfb\x09\xf8)\x80\x1d\xb6\x8f\xcf\x02\xda~\xa6}\ +\x86v\xdc\x981\x06\x045\x02NB\x90\xf7\xf0t\xb3\ +\x1f\x19\xb8\x80\xe5\xeeOo5\x84\xa9>\x040\x1aD\ +!\xc0MEs\xe7F\x97diM\xc8\xcc\xf9N\xa7\ +3'\xfc0\xb9\xe6\x1b\xe9\x98\x8c\xaaB\x07\x92\x142\ +\xebU6\xeb\xf1\xbc$\x85}\xd8\xbb;\xd1\x9c\xfa\xe1\ +\x0f0\xfd\x9e;\x04\x13\x1b\xecD\xec\x02\xdcJ\x92\xcf\ +\x03<\x14\xf5$\x04\xd8\x84\xd5\x85\xbf_P\xeba,\ +3\x96\x0f\x8a\x0fB}\xe6\xa43\xf5L\x08\x1enB\ +\xb5\x13J\x01\xb5\x13a\xaa\x87V3\x07|X\x0eI\ +4\xe5oK\x8b|\x1f\xcc\xd4\xfa\xd1h%\x00\xa7\x02\ +<\xbc\xaaA\xb4\xf0 m\x0c\x0e+:+\x9a\xad\x1e\ +\xfd\xaf\xdf\x091k\xea\xfe\xfe\xf8\xe1H\xdf\x05R\xca\ +|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\x04<\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -2396,6 +2453,35 @@ f\xa2\xd1\xa5\x5c\x22\xb4\x91SZ\xd5u\xd7\x0a\xd2]\ |\x01\x85\x09\x800\x7fss\xd3{\xf6\x7fABG\ Y\x01\x05l*\xfc\x00!\x00\x12\xf1%U\xb6\x0e\x00\ \x00\x00\x00IEND\xaeB`\x82\ +\x00\x00\x01\xad\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00 \x00\x00\x00 \x08\x03\x00\x00\x00D\xa4\x8a\xc6\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x09pHYs\x00\x00\x0d\xd7\x00\x00\x0d\xd7\x01B(\ +\x9bx\x00\x00\x00\x07tIME\x07\xd9\x0c\x1c\x03\x1c\ +\x0e%S,b\x00\x00\x00uPLTE\x00\x00\x00\ +\x13\x13\x13\x0a\x0a\x0a\x0b\x0b\x0b\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc6\xc6\xc8\xc7\xc7\ +\xc9\xc9\xc9\xcb\xcc\xcc\xce\xce\xce\xd0\xd0\xd0\xd2\xd4\xd4\xd6\ +\xd7\xd7\xd8\xd7\xd7\xd9\xd8\xd8\xda\xd9\xd9\xdb\xda\xda\xdb\xda\ +\xda\xdc\xdb\xdb\xdc\xdb\xdb\xdd\xdd\xdd\xdf\xde\xde\xdf\xe2\xe2\ +\xe3\xe3\xe3\xe5\xe3\xe3\xe6\xe4\xe4\xe6\xe6\xe6\xe7\xe7\xe7\xe9\ +\xe9\xe9\xea\xeb\xeb\xec\xed\xed\xee\xf0\xf0\xf1\xf3\xf3\xf4\xff\ +\xff\xff\xd3\x9b\xcc\x0e\x00\x00\x00\x0atRNS\x00\x09\ +\x15\x15\x1825678\xb5\xcc\xc0\x1e\x00\x00\x00\x01\ +bKGD&Z\x08\x98\xb5\x00\x00\x00\x9bIDA\ +Tx\xda\xd5\x93\xcb\x0e\x820\x10\x00\x8b\x0aEP|\ +u\xc1G)\x94\x02\xff\xff\x89.]\x0e\x18\xccr1\ +F\xe72\xd9d\xd26\x9bT\xfc\x02\xc1\x86%\x10a\ +\xcf\x12\x0a\xd9)\x86N\x0a\xd9^\x18Z\x0c\xdc\x91\xc1\ +a\xd0\x1c\x18\x1a\x0c\xec\x9e\xc1bP'\x9et\x97\xce\ +\x95\xd4\x18\x94=q#\xddIWR\x89\x81\x1e\x03E\ +\x82\x97IO\x02\x98\x8b\x82G\x01\x9e\xec\x8d\x8aj\xf1\ +\x84\xcf\xbc!W\x03\x90\x8d\x02oR^-^\xf1?\ +{\xe0\x83\xc8\x1a=`\xce\xc6\xebd\xa6\x93\x8b\xc4*\ +\xde2\xc4\xeb/|\xcd'\xec\xbfO\xbf\x90M\x1a\x0a\ +\x00\x00\x00\x00IEND\xaeB`\x82\ \x00\x00\x02\x92\ \x89\ PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ @@ -2632,10 +2718,20 @@ qt_resource_name = b"\ \x00d\ \x00o\x00c\x00u\x00m\x00e\x00n\x00t\x00-\x00s\x00a\x00v\x00e\x00.\x00p\x00n\x00g\ \ +\x00\x15\ +\x02\xb4\x1f\x07\ +\x00o\ +\x00f\x00f\x00i\x00c\x00e\x00-\x00c\x00h\x00a\x00r\x00t\x00-\x00l\x00i\x00n\x00e\ +\x00.\x00p\x00n\x00g\ \x00\x10\ \x03\xe6\xd3g\ \x00d\ \x00r\x00a\x00w\x00-\x00e\x00l\x00l\x00i\x00p\x00s\x00e\x00.\x00p\x00n\x00g\ +\x00\x13\ +\x07\xd6O\x07\ +\x00v\ +\x00i\x00e\x00w\x00-\x00f\x00o\x00r\x00m\x00-\x00t\x00a\x00b\x00l\x00e\x00.\x00p\ +\x00n\x00g\ \x00\x12\ \x09\xb3>\xc7\ \x00d\ @@ -2653,7 +2749,7 @@ 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\x1c\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x1e\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\x01\x9f{C\xf1'\ @@ -2661,11 +2757,13 @@ qt_resource_struct = b"\ \x00\x00\x01\x9f\x7f\xa8\xa8)\ \x00\x00\x02\xec\x00\x00\x00\x00\x00\x01\x00\x00tV\ \x00\x00\x01\x9f{0\xc99\ +\x00\x00\x03\xa4\x00\x00\x00\x00\x00\x01\x00\x00\x8d\xaa\ +\x00\x00\x01\x9f\x84\x8f\x00\xb9\ \x00\x00\x01\xc4\x00\x00\x00\x00\x00\x01\x00\x00D2\ \x00\x00\x01\x9f\x7f&\x83\xcd\ \x00\x00\x01\xa4\x00\x00\x00\x00\x00\x01\x00\x00 bool: + if self._view_has_focus(self.ui.documentTreeView): + return self._copy_tree_component(self.ui.documentTreeView) + if self._view_has_focus(self.ui.treeView): + return self._copy_tree_component(self.ui.treeView) + return self.ui.graphView.copy_selection() + + def cut_selection(self) -> None: + if self._view_has_focus(self.ui.documentTreeView): + index = self.ui.documentTreeView.currentIndex() + component_id = index.data(COMPONENT_ID_ROLE) + if component_id and self._copy_tree_component(self.ui.documentTreeView): + self.document_controller.delete_component(component_id) + return + if self._view_has_focus(self.ui.treeView): + self._copy_tree_component(self.ui.treeView) + return + self.ui.graphView.cut_selection() + + def paste_selection(self) -> None: + if self._view_has_focus(self.ui.documentTreeView): + self._paste_into_document_tree() + return + self.ui.graphView.paste_selection() + + def _copy_tree_component(self, tree) -> bool: + component = tree.currentIndex().data(COMPONENT_ROLE) + if not isinstance(component, dict): + return False + mime_data = QMimeData() + mime_data.setData( + COMPONENT_MIME_TYPE, + QByteArray(json.dumps(component).encode("utf-8")), + ) + QApplication.clipboard().setMimeData(mime_data) + return True + + def _paste_into_document_tree(self) -> None: + index = self.ui.documentTreeView.currentIndex() + kind = index.data(ITEM_KIND_ROLE) + owner_id = None + if kind == "current-component": + component_id = index.data(COMPONENT_ID_ROLE) + component = ( + self.document_controller.document.find_component(component_id) + if self.document_controller.document + else None + ) + if component is None or component.implementation_kind != "graph": + QMessageBox.warning( + self, "Cannot Paste", "Select a graph component or the document root." + ) + return + owner_id = component.id + elif kind != "current-document": + QMessageBox.warning( + self, "Cannot Paste", "Select a graph component or the document root." + ) + return + try: + components, connections = self._clipboard_components() + self.document_controller.paste_components_to( + owner_id, components, connections + ) + except ValueError as error: + QMessageBox.warning(self, "Cannot Paste", str(error)) + + @staticmethod + def _clipboard_components() -> tuple[list[Component], list]: + mime_data = QApplication.clipboard().mimeData() + try: + if mime_data.hasFormat(SELECTION_MIME_TYPE): + payload = json.loads( + bytes(mime_data.data(SELECTION_MIME_TYPE)).decode("utf-8") + ) + return ( + [Component.from_dict(item) for item in payload.get("components", [])], + [Connection.from_dict(item) for item in payload.get("connections", [])], + ) + if mime_data.hasFormat(COMPONENT_MIME_TYPE): + payload = json.loads( + bytes(mime_data.data(COMPONENT_MIME_TYPE)).decode("utf-8") + ) + return [Component.from_dict(payload)], [] + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + raise ValueError("The clipboard does not contain a valid component") from error + raise ValueError("The clipboard does not contain a BEdit component") + + @staticmethod + def _view_has_focus(view) -> bool: + focus = QApplication.focusWidget() + return focus is view or (focus is not None and view.isAncestorOf(focus)) + @Slot() def navigate_up(self) -> None: if self._resolve_source_edits(): @@ -560,6 +691,8 @@ class MainWindow(QMainWindow): def show_library_context_menu(self, position) -> None: tree_view = self.ui.documentTreeView index = tree_view.indexAt(position) + if index.isValid(): + tree_view.setCurrentIndex(index) kind = index.data(ITEM_KIND_ROLE) if kind == "current-component": component_id = index.data(COMPONENT_ID_ROLE) @@ -577,6 +710,11 @@ class MainWindow(QMainWindow): options_action = menu.addAction("Component Options…") ports_action = menu.addAction("Port Options…") parameters_action = menu.addAction("Parameter Options…") + menu.addSeparator() + copy_action = menu.addAction("Copy") + cut_action = menu.addAction("Cut") + paste_action = menu.addAction("Paste") + paste_action.setEnabled(component.implementation_kind == "graph") delete_action = menu.addAction("Delete") selected = menu.exec(tree_view.viewport().mapToGlobal(position)) if graph_action is not None and selected is graph_action: @@ -589,6 +727,13 @@ class MainWindow(QMainWindow): self.show_component_port_options(component_id) elif selected is parameters_action: self.show_component_parameter_options(component_id) + elif selected is copy_action: + self._copy_tree_component(tree_view) + elif selected is cut_action: + if self._copy_tree_component(tree_view): + self.document_controller.delete_component(component_id) + elif selected is paste_action: + self._paste_into_document_tree() elif selected is delete_action: answer = QMessageBox.question( self, @@ -604,24 +749,34 @@ class MainWindow(QMainWindow): menu = QMenu(self) graph_action = menu.addAction("New Graph Block") text_action = menu.addAction("New Text Block") + menu.addSeparator() + paste_action = menu.addAction("Paste") selected = menu.exec(tree_view.viewport().mapToGlobal(position)) if selected is graph_action: self.document_controller.add_root("graph") elif selected is text_action: self.document_controller.add_root("text") + elif selected is paste_action: + self._paste_into_document_tree() @Slot(object) def show_external_library_context_menu(self, position) -> None: tree = self.ui.treeView index = tree.indexAt(position) + if index.isValid(): + tree.setCurrentIndex(index) component = index.data(COMPONENT_INSTANCE_ROLE) if not isinstance(component, Component): return menu = QMenu(self) + copy_action = menu.addAction("Copy") + menu.addSeparator() ports_action = menu.addAction("Port Options…") parameters_action = menu.addAction("Parameter Options…") selected = menu.exec(tree.viewport().mapToGlobal(position)) - if selected is ports_action: + if selected is copy_action: + self._copy_tree_component(tree) + 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) @@ -773,6 +928,7 @@ class MainWindow(QMainWindow): event.ignore() return self.settings.setValue("window/geometry", self.saveGeometry()) + self._simulation_window.close() self.simulation.shutdown() self.log.info("BEdit closed") self.application_logger.removeHandler(self.log_handler) diff --git a/BEdit/src/bedit/gui/simulation_window.py b/BEdit/src/bedit/gui/simulation_window.py index d4fee22..9b9a3c9 100644 --- a/BEdit/src/bedit/gui/simulation_window.py +++ b/BEdit/src/bedit/gui/simulation_window.py @@ -1,11 +1,12 @@ import re from pathlib import Path -from PySide6.QtCore import Signal +from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import QFileDialog, QMainWindow, QMessageBox from bedit.core.simulation.openmodelica import SimulationMessage, SimulationProgress from bedit.core.simulation.results import ( + SimulationExecutionResult, SimulationResults, load_simulation_results, save_simulation_results, @@ -31,6 +32,10 @@ class SimulationWindow(QMainWindow): self.results = SimulationResults() self._connect_actions() self._populate_view_menu() + self.splitDockWidget( + self.ui.statusDock, self.ui.logDock, Qt.Orientation.Vertical + ) + self.ui.statusDock.setFixedHeight(self.ui.statusDock.sizeHint().height()) self.progressReceived.connect(self._show_progress) self.messageReceived.connect(self._show_message) self.simulationFinished.connect(self._show_finished) @@ -39,15 +44,18 @@ class SimulationWindow(QMainWindow): def _connect_actions(self) -> None: self.ui.actionOpen.triggered.connect(self.open_results) self.ui.actionSave.triggered.connect(self.save_results) + self.ui.actionSaveAs.triggered.connect(self.save_results_as) self.ui.actionClear.triggered.connect(self.clear) self.ui.actionExit.triggered.connect(self.close) self.ui.actionAbout.triggered.connect(self.show_about) self.ui.actionAboutQt.triggered.connect( lambda: QMessageBox.aboutQt(self, "About Qt Framework") ) + self.ui.actionToggleResults.toggled.connect(self.ui.centralWidget.setVisible) def _populate_view_menu(self) -> None: - for panel in (self.ui.statusDock, self.ui.logDock, self.ui.resultsDock): + self.ui.menuPanels.addAction(self.ui.actionToggleResults) + for panel in (self.ui.statusDock, self.ui.logDock): self.ui.menuPanels.addAction(panel.toggleViewAction()) self.ui.menuToolbars.addAction(self.ui.fileToolbar.toggleViewAction()) @@ -97,15 +105,18 @@ class SimulationWindow(QMainWindow): ) def load_result_views(self) -> None: - """Populate custom plots from ``self.results.traces``. + """Populate custom plots from ``self.results.data`` and traces. This is the intended integration point for a future plotting widget. """ + data_count = len(self.results.data) trace_count = len(self.results.traces) - if trace_count: + if data_count or trace_count: + sample_count = len(next(iter(self.results.data.values()), [])) self.ui.resultsPlaceholder.setText( - f"{trace_count} trace(s) loaded; add graph rendering here." + f"{data_count} data column(s), {sample_count} sample(s), and " + f"{trace_count} configured trace(s) loaded; add graph rendering here." ) def open_results(self) -> None: @@ -113,7 +124,7 @@ class SimulationWindow(QMainWindow): self, "Open Simulation Results", "", - "BEdit Simulation Results (*.json);;JSON Files (*.json)", + "BEdit Binary Simulation Results (*.ber);;JSON Simulation Results (*.json)", ) if not file_name: return @@ -131,18 +142,27 @@ class SimulationWindow(QMainWindow): def save_results(self) -> None: file_path = self._file_path if file_path is None: - default_name = f"{_safe_file_stem(self.results.model_name)}-results.json" - file_name, _selected_filter = QFileDialog.getSaveFileName( - self, - "Save Simulation Results", - default_name, - "BEdit Simulation Results (*.json);;JSON Files (*.json)", - ) - if not file_name: - return - file_path = Path(file_name) - if file_path.suffix.lower() != ".json": - file_path = file_path.with_suffix(".json") + self.save_results_as() + return + self._save_results_to(file_path) + + def save_results_as(self) -> None: + default_name = f"{_safe_file_stem(self.results.model_name)}-results.ber" + file_name, selected_filter = QFileDialog.getSaveFileName( + self, + "Save Simulation Results As", + default_name, + "BEdit Binary Simulation Results (*.ber);;JSON Simulation Results (*.json)", + ) + if not file_name: + return + file_path = Path(file_name) + if file_path.suffix.lower() not in {".ber", ".json"}: + suffix = ".json" if selected_filter.startswith("JSON") else ".ber" + file_path = file_path.with_suffix(suffix) + self._save_results_to(file_path) + + def _save_results_to(self, file_path: Path) -> None: try: save_simulation_results(file_path, self.results) except OSError as error: @@ -209,7 +229,12 @@ class SimulationWindow(QMainWindow): def _show_finished(self, result) -> None: self._running = False self.results.status.update(phase="Simulation finished", progress=10000) - self.results.metadata["processResult"] = result + if isinstance(result, SimulationExecutionResult): + self.results.data = result.data + self.results.metadata["processReturnCode"] = result.return_code + self.results.metadata["sourceResultFile"] = Path(result.result_file).name + else: + self.results.metadata["processResult"] = result self.ui.progressBar.setValue(10000) self.ui.statusLabel.setText("Simulation finished") self.load_result_views() diff --git a/BEdit/ui/main_window.ui b/BEdit/ui/main_window.ui index 08cac92..605041c 100644 --- a/BEdit/ui/main_window.ui +++ b/BEdit/ui/main_window.ui @@ -664,7 +664,7 @@ - :/icons/icons/configure.png:/icons/icons/configure.png + :/icons/icons/view-form-table.png:/icons/icons/view-form-table.png Graph Parameters @@ -706,7 +706,7 @@ - :/icons/icons/configure.png:/icons/icons/configure.png + :/icons/icons/office-chart-line.png:/icons/icons/office-chart-line.png Simulation Window diff --git a/BEdit/ui/simulation_window.ui b/BEdit/ui/simulation_window.ui index 1d6615e..8f4dc70 100644 --- a/BEdit/ui/simulation_window.ui +++ b/BEdit/ui/simulation_window.ui @@ -2,30 +2,77 @@ SimulationWindow - 00900650 - Simulation + + + 0 + 0 + 900 + 650 + + + + Simulation + + + + :/icons/icons/office-chart-line.png:/icons/icons/office-chart-line.png + - 00 + + + + + Simulation graphs and result controls can be added here. + + + Qt::AlignmentFlag::AlignCenter + + + + + + + 0 + 0 + 900 + 24 + + - &File + + &File + + - &View - &Panels - &Toolbars + + &View + + + + &Panels + + + + + &Toolbars + + - &Help + + &Help + @@ -34,58 +81,164 @@ - File - Qt::ToolButtonStyle::ToolButtonIconOnly - TopToolBarArea + + File + + + Qt::ToolButtonStyle::ToolButtonIconOnly + + + TopToolBarArea + + + false + + - - Results - 2 - - - Simulation graphs and result controls can be added here.Qt::AlignmentFlag::AlignCenter - - - - Status - 8 + + Status + + + 8 + - - No simulation has been run yet. - 100000%p% - Time: 0 s + + + + + Time: 0 s + + + + + + + 10000 + + + 0 + + + %p% + + + + + + + No simulation has been run yet. + + + - Log - 8 + + false + + + QDockWidget::DockWidgetFeature::DockWidgetFloatable|QDockWidget::DockWidgetFeature::DockWidgetMovable + + + Log + + + 8 + - true + + + + true + + + - :/icons/icons/document-open.png:/icons/icons/document-open.png - &Open…Ctrl+O + + + :/icons/icons/document-open.png:/icons/icons/document-open.png + + + &Open… + + + Ctrl+O + - :/icons/icons/document-save.png:/icons/icons/document-save.png - &Save…Ctrl+S + + + :/icons/icons/document-save.png:/icons/icons/document-save.png + + + &Save… + + + Ctrl+S + - :/icons/icons/document-new.png:/icons/icons/document-new.png - &Clear + + + :/icons/icons/document-new.png:/icons/icons/document-new.png + + + &Clear + + + + + + :/icons/icons/document-save-as.png:/icons/icons/document-save-as.png + + + Save &As… + + + Ctrl+Shift+S + + + + + E&xit + + + Ctrl+W + + + + + &About Simulation Window + + + + + About &Qt + + + + + true + + + true + + + Results + - E&xitCtrl+W - &About Simulation Window - About &Qt - + + +