rfem-table-export 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- rfem_export/__init__.py +19 -0
- rfem_export/_version.py +24 -0
- rfem_export/app.py +262 -0
- rfem_export/catalog.py +266 -0
- rfem_export/connection.py +147 -0
- rfem_export/excel.py +161 -0
- rfem_export/extract.py +205 -0
- rfem_table_export-0.2.0.dist-info/METADATA +199 -0
- rfem_table_export-0.2.0.dist-info/RECORD +12 -0
- rfem_table_export-0.2.0.dist-info/WHEEL +4 -0
- rfem_table_export-0.2.0.dist-info/entry_points.txt +3 -0
- rfem_table_export-0.2.0.dist-info/licenses/LICENSE +21 -0
rfem_export/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""RFEM 6 table extraction TUI.
|
|
2
|
+
|
|
3
|
+
A terminal UI that attaches to a running RFEM 6 process, lets the user pick
|
|
4
|
+
input and result tables from a tree, and exports them to a new Excel workbook
|
|
5
|
+
(one sheet per table plus an info sheet).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
# Installed distribution (wheel/sdist, including via uvx/pip).
|
|
12
|
+
__version__ = version("rfem-table-export")
|
|
13
|
+
except PackageNotFoundError: # pragma: no cover
|
|
14
|
+
try:
|
|
15
|
+
# Built from source: hatch-vcs writes this file at build time.
|
|
16
|
+
from ._version import __version__
|
|
17
|
+
except ImportError:
|
|
18
|
+
# Running from an unbuilt checkout with no tags reachable.
|
|
19
|
+
__version__ = "0.0.0+unknown"
|
rfem_export/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.2.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 2, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
rfem_export/app.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Textual TUI for exporting RFEM 6 tables to Excel.
|
|
2
|
+
|
|
3
|
+
Flow:
|
|
4
|
+
1. Connect to the running RFEM process and read the active model.
|
|
5
|
+
2. Confirm the model in a banner (user verifies it's the right one).
|
|
6
|
+
3. Pick input/result tables from a tree.
|
|
7
|
+
4. Press 'e' to export -> a new timestamped workbook under results/.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
|
|
14
|
+
from textual import work
|
|
15
|
+
from textual.app import App, ComposeResult
|
|
16
|
+
from textual.containers import Horizontal, Vertical
|
|
17
|
+
from textual.widgets import Footer, Header, Label, RichLog, Static, Tree
|
|
18
|
+
from textual.widgets.tree import TreeNode
|
|
19
|
+
|
|
20
|
+
from . import catalog, connection, excel, extract
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RfemExportApp(App):
|
|
24
|
+
CSS = """
|
|
25
|
+
#model_banner {
|
|
26
|
+
height: auto;
|
|
27
|
+
padding: 0 1;
|
|
28
|
+
background: $boost;
|
|
29
|
+
color: $text;
|
|
30
|
+
border: round $primary;
|
|
31
|
+
}
|
|
32
|
+
#body { height: 1fr; }
|
|
33
|
+
#tree_pane { width: 45%; border: round $secondary; }
|
|
34
|
+
#log_pane { width: 55%; border: round $secondary; }
|
|
35
|
+
#options { height: auto; padding: 0 1; color: $text-muted; }
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
BINDINGS = [
|
|
39
|
+
("space", "toggle", "Toggle table"),
|
|
40
|
+
("a", "select_all", "Select all"),
|
|
41
|
+
("n", "select_none", "Clear"),
|
|
42
|
+
("e", "export", "Export"),
|
|
43
|
+
("r", "refresh", "Reconnect"),
|
|
44
|
+
("q", "quit", "Quit"),
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
def __init__(self, api_key: str | None = None) -> None:
|
|
48
|
+
super().__init__()
|
|
49
|
+
self._api_key = api_key
|
|
50
|
+
self._app = None # rfem.Application
|
|
51
|
+
self._model = connection.ModelInfo()
|
|
52
|
+
self._units = connection.UnitSummary()
|
|
53
|
+
self._selected: set[str] = set()
|
|
54
|
+
self._exporting = False
|
|
55
|
+
|
|
56
|
+
# -- layout ----------------------------------------------------------
|
|
57
|
+
def compose(self) -> ComposeResult:
|
|
58
|
+
yield Header(show_clock=True)
|
|
59
|
+
yield Static("Connecting to RFEM…", id="model_banner")
|
|
60
|
+
with Horizontal(id="body"):
|
|
61
|
+
with Vertical(id="tree_pane"):
|
|
62
|
+
yield self._build_tree()
|
|
63
|
+
with Vertical(id="log_pane"):
|
|
64
|
+
yield RichLog(id="log", highlight=True, markup=True, wrap=True)
|
|
65
|
+
yield Label("", id="options")
|
|
66
|
+
yield Footer()
|
|
67
|
+
|
|
68
|
+
def _build_tree(self) -> Tree:
|
|
69
|
+
tree: Tree = Tree("Available Tables", id="tree")
|
|
70
|
+
tree.root.expand()
|
|
71
|
+
for group in catalog.TREE:
|
|
72
|
+
self._add_group(tree.root, group)
|
|
73
|
+
return tree
|
|
74
|
+
|
|
75
|
+
def _add_group(self, parent: TreeNode, group: catalog.Group) -> None:
|
|
76
|
+
node = parent.add(group.name, expand=True, data=None)
|
|
77
|
+
for table in group.tables:
|
|
78
|
+
node.add_leaf(self._leaf_label(table.key, table.name), data=table.key)
|
|
79
|
+
for sub in group.subgroups:
|
|
80
|
+
self._add_group(node, sub)
|
|
81
|
+
|
|
82
|
+
def _leaf_label(self, key: str, name: str) -> str:
|
|
83
|
+
mark = "☑" if key in self._selected else "☐" # ☑ / ☐
|
|
84
|
+
return f"{mark} {name}"
|
|
85
|
+
|
|
86
|
+
# -- lifecycle -------------------------------------------------------
|
|
87
|
+
def on_mount(self) -> None:
|
|
88
|
+
self._refresh_options()
|
|
89
|
+
self.connect_rfem()
|
|
90
|
+
|
|
91
|
+
@work(thread=True)
|
|
92
|
+
def connect_rfem(self) -> None:
|
|
93
|
+
self._set_banner("Connecting to RFEM…")
|
|
94
|
+
try:
|
|
95
|
+
api_key = connection.resolve_api_key(self._api_key)
|
|
96
|
+
if not api_key:
|
|
97
|
+
self._log("[red]No API key found (config.ini / RFEM_API_KEY).[/red]")
|
|
98
|
+
self._app = connection.connect(api_key)
|
|
99
|
+
self._model = connection.get_model_info(self._app)
|
|
100
|
+
self._units = connection.get_unit_summary(self._app)
|
|
101
|
+
self._set_banner(self._banner_text())
|
|
102
|
+
self._log(
|
|
103
|
+
f"[green]Connected.[/green] Active model: [b]{self._model.name}[/b]"
|
|
104
|
+
)
|
|
105
|
+
self._log(f" Path: {self._model.path}")
|
|
106
|
+
self._log("Verify this is the correct model before exporting.")
|
|
107
|
+
except Exception as exc:
|
|
108
|
+
self._set_banner("[red]Not connected — RFEM not reachable[/red]")
|
|
109
|
+
self._log(f"[red]Connection failed:[/red] {exc}")
|
|
110
|
+
self._log("Is RFEM running with a model open? Press 'r' to retry.")
|
|
111
|
+
|
|
112
|
+
def _banner_text(self) -> str:
|
|
113
|
+
return (
|
|
114
|
+
f"[b]Model:[/b] {self._model.name or '(unknown)'} "
|
|
115
|
+
f"[b]Units:[/b] {self._units.system} "
|
|
116
|
+
f"[dim]{self._model.path}[/dim]"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# -- actions ---------------------------------------------------------
|
|
120
|
+
def action_toggle(self) -> None:
|
|
121
|
+
tree = self.query_one("#tree", Tree)
|
|
122
|
+
node = tree.cursor_node
|
|
123
|
+
if node is None or node.data is None:
|
|
124
|
+
return
|
|
125
|
+
key = node.data
|
|
126
|
+
if key in self._selected:
|
|
127
|
+
self._selected.discard(key)
|
|
128
|
+
else:
|
|
129
|
+
self._selected.add(key)
|
|
130
|
+
node.set_label(self._leaf_label(key, catalog.get_table(key).name))
|
|
131
|
+
self._refresh_options()
|
|
132
|
+
|
|
133
|
+
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
134
|
+
# Enter/click on a leaf toggles it too.
|
|
135
|
+
if event.node.data is not None:
|
|
136
|
+
self.action_toggle()
|
|
137
|
+
|
|
138
|
+
def action_select_all(self) -> None:
|
|
139
|
+
self._set_all(True)
|
|
140
|
+
|
|
141
|
+
def action_select_none(self) -> None:
|
|
142
|
+
self._set_all(False)
|
|
143
|
+
|
|
144
|
+
def _set_all(self, selected: bool) -> None:
|
|
145
|
+
tree = self.query_one("#tree", Tree)
|
|
146
|
+
self._walk_leaves(tree.root, selected)
|
|
147
|
+
self._refresh_options()
|
|
148
|
+
|
|
149
|
+
def _walk_leaves(self, node: TreeNode, selected: bool) -> None:
|
|
150
|
+
for child in node.children:
|
|
151
|
+
key = child.data
|
|
152
|
+
if key is None:
|
|
153
|
+
self._walk_leaves(child, selected)
|
|
154
|
+
continue
|
|
155
|
+
if selected:
|
|
156
|
+
self._selected.add(key)
|
|
157
|
+
else:
|
|
158
|
+
self._selected.discard(key)
|
|
159
|
+
child.set_label(self._leaf_label(key, catalog.get_table(key).name))
|
|
160
|
+
|
|
161
|
+
def action_refresh(self) -> None:
|
|
162
|
+
self.connect_rfem()
|
|
163
|
+
|
|
164
|
+
def action_export(self) -> None:
|
|
165
|
+
if self._exporting:
|
|
166
|
+
self._log("[yellow]Export already in progress…[/yellow]")
|
|
167
|
+
return
|
|
168
|
+
if self._app is None:
|
|
169
|
+
self._log("[red]Not connected to RFEM. Press 'r' to connect.[/red]")
|
|
170
|
+
return
|
|
171
|
+
if not self._selected:
|
|
172
|
+
self._log(
|
|
173
|
+
"[yellow]No tables selected. Use space / 'a' to pick tables.[/yellow]"
|
|
174
|
+
)
|
|
175
|
+
return
|
|
176
|
+
self.run_export()
|
|
177
|
+
|
|
178
|
+
@work(thread=True)
|
|
179
|
+
def run_export(self) -> None:
|
|
180
|
+
self._exporting = True
|
|
181
|
+
try:
|
|
182
|
+
keys = [t.key for t in catalog.iter_tables() if t.key in self._selected]
|
|
183
|
+
self._log(f"[b]Exporting {len(keys)} table(s)…[/b]")
|
|
184
|
+
|
|
185
|
+
sheets: list[tuple[str, object]] = []
|
|
186
|
+
reports: list[excel.SheetReport] = []
|
|
187
|
+
for key in keys:
|
|
188
|
+
table = catalog.get_table(key)
|
|
189
|
+
self._log(f" • {table.name}…")
|
|
190
|
+
result = extract.extract(self._app, table)
|
|
191
|
+
for w in result.warnings:
|
|
192
|
+
self._log(f" [yellow]warn:[/yellow] {w}")
|
|
193
|
+
sheets.append((table.name, result.df))
|
|
194
|
+
reports.append(
|
|
195
|
+
excel.SheetReport(
|
|
196
|
+
table_name=table.name,
|
|
197
|
+
sheet_name=table.name, # final sheet name resolved in writer
|
|
198
|
+
rows=result.rows,
|
|
199
|
+
warnings=result.warnings,
|
|
200
|
+
)
|
|
201
|
+
)
|
|
202
|
+
self._log(f" {result.rows} row(s)")
|
|
203
|
+
|
|
204
|
+
path = excel.write_workbook(
|
|
205
|
+
model=self._model,
|
|
206
|
+
units=self._units,
|
|
207
|
+
scope="All elements",
|
|
208
|
+
sheets=sheets,
|
|
209
|
+
reports=reports,
|
|
210
|
+
)
|
|
211
|
+
self._log(f"[green]Export complete:[/green] {path}")
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
self._log(f"[red]Export failed:[/red] {exc}")
|
|
214
|
+
finally:
|
|
215
|
+
self._exporting = False
|
|
216
|
+
|
|
217
|
+
# -- helpers ---------------------------------------------------------
|
|
218
|
+
def _refresh_options(self) -> None:
|
|
219
|
+
try:
|
|
220
|
+
label = self.query_one("#options", Label)
|
|
221
|
+
except Exception:
|
|
222
|
+
return
|
|
223
|
+
label.update(
|
|
224
|
+
f"Scope: All elements (v1) | "
|
|
225
|
+
f"Selected: {len(self._selected)} table(s) | "
|
|
226
|
+
f"space=toggle a=all n=none e=export r=reconnect q=quit"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _set_banner(self, text: str) -> None:
|
|
230
|
+
self.call_from_thread(self._set_banner_sync, text)
|
|
231
|
+
|
|
232
|
+
def _set_banner_sync(self, text: str) -> None:
|
|
233
|
+
self.query_one("#model_banner", Static).update(text)
|
|
234
|
+
|
|
235
|
+
def _log(self, text: str) -> None:
|
|
236
|
+
self.call_from_thread(self._log_sync, text)
|
|
237
|
+
|
|
238
|
+
def _log_sync(self, text: str) -> None:
|
|
239
|
+
self.query_one("#log", RichLog).write(text)
|
|
240
|
+
|
|
241
|
+
def on_unmount(self) -> None:
|
|
242
|
+
if self._app is not None:
|
|
243
|
+
try:
|
|
244
|
+
self._app.close_connection()
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def main() -> None:
|
|
250
|
+
parser = argparse.ArgumentParser(description="RFEM 6 table export TUI")
|
|
251
|
+
parser.add_argument(
|
|
252
|
+
"--api-key",
|
|
253
|
+
dest="api_key",
|
|
254
|
+
default=None,
|
|
255
|
+
help="RFEM API key (defaults to RFEM_API_KEY then config.ini)",
|
|
256
|
+
)
|
|
257
|
+
args = parser.parse_args()
|
|
258
|
+
RfemExportApp(api_key=args.api_key).run()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
main()
|
rfem_export/catalog.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Curated catalog of exportable RFEM tables, organized as a nested tree that
|
|
2
|
+
mirrors the RFEM 6 GUI navigators.
|
|
3
|
+
|
|
4
|
+
Hierarchy (top-level navigator -> category -> table), e.g.:
|
|
5
|
+
|
|
6
|
+
Structure
|
|
7
|
+
Basic Objects
|
|
8
|
+
Materials, Cross-Sections, Nodes, Lines, Members, Surfaces
|
|
9
|
+
Types for Nodes
|
|
10
|
+
Nodal Supports
|
|
11
|
+
Types for Members
|
|
12
|
+
Member Hinges
|
|
13
|
+
Load Cases and Combinations
|
|
14
|
+
Load Cases, Load Combinations, Result Combinations
|
|
15
|
+
Loads
|
|
16
|
+
Nodal Loads, Member Loads, Surface Loads
|
|
17
|
+
Results
|
|
18
|
+
Nodes / Members / Lines / Surfaces -> their result tables
|
|
19
|
+
|
|
20
|
+
Two kinds of tables:
|
|
21
|
+
|
|
22
|
+
* ``input`` — model objects, extracted by enumerating object IDs of a given
|
|
23
|
+
``ObjectType`` and reading each object.
|
|
24
|
+
* ``result`` — analysis result tables, extracted per loading via
|
|
25
|
+
``get_result_table``.
|
|
26
|
+
|
|
27
|
+
Add ``TableDef`` entries to the ``TREE`` below to extend coverage — extraction
|
|
28
|
+
and the TUI pick everything up automatically.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
|
|
35
|
+
import dlubal.api.rfem as rfem
|
|
36
|
+
import dlubal.api.rfem.results as rfem_results
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class TableDef:
|
|
41
|
+
key: str # stable unique id
|
|
42
|
+
name: str # display name (also seeds the sheet name; must stay unique)
|
|
43
|
+
kind: str # "input" | "result"
|
|
44
|
+
# For inputs:
|
|
45
|
+
object_type: object | None = None # rfem.ObjectType.* enum value
|
|
46
|
+
proto: object | None = None # proto class, e.g. rfem.structure_core.Node
|
|
47
|
+
# For results:
|
|
48
|
+
result_table: object | None = None # rfem_results.ResultTable.* enum value
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class Group:
|
|
53
|
+
"""A navigator node: holds tables and/or nested subgroups."""
|
|
54
|
+
|
|
55
|
+
name: str
|
|
56
|
+
tables: tuple[TableDef, ...] = field(default_factory=tuple)
|
|
57
|
+
subgroups: tuple[Group, ...] = field(default_factory=tuple)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _inp(key, name, object_type, proto) -> TableDef:
|
|
61
|
+
return TableDef(
|
|
62
|
+
key=key, name=name, kind="input", object_type=object_type, proto=proto
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _res(key, name, result_table) -> TableDef:
|
|
67
|
+
return TableDef(key=key, name=name, kind="result", result_table=result_table)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_RT = rfem_results.ResultTable
|
|
71
|
+
_OT = rfem.ObjectType
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
# Tree definition — mirrors the RFEM 6 GUI navigators
|
|
76
|
+
# --------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
TREE: tuple[Group, ...] = (
|
|
79
|
+
Group(
|
|
80
|
+
"Structure",
|
|
81
|
+
subgroups=(
|
|
82
|
+
Group(
|
|
83
|
+
"Basic Objects",
|
|
84
|
+
tables=(
|
|
85
|
+
_inp(
|
|
86
|
+
"materials",
|
|
87
|
+
"Materials",
|
|
88
|
+
_OT.OBJECT_TYPE_MATERIAL,
|
|
89
|
+
rfem.structure_core.Material,
|
|
90
|
+
),
|
|
91
|
+
_inp(
|
|
92
|
+
"cross_sections",
|
|
93
|
+
"Cross-Sections",
|
|
94
|
+
_OT.OBJECT_TYPE_CROSS_SECTION,
|
|
95
|
+
rfem.structure_core.CrossSection,
|
|
96
|
+
),
|
|
97
|
+
_inp(
|
|
98
|
+
"nodes", "Nodes", _OT.OBJECT_TYPE_NODE, rfem.structure_core.Node
|
|
99
|
+
),
|
|
100
|
+
_inp(
|
|
101
|
+
"lines", "Lines", _OT.OBJECT_TYPE_LINE, rfem.structure_core.Line
|
|
102
|
+
),
|
|
103
|
+
_inp(
|
|
104
|
+
"members",
|
|
105
|
+
"Members",
|
|
106
|
+
_OT.OBJECT_TYPE_MEMBER,
|
|
107
|
+
rfem.structure_core.Member,
|
|
108
|
+
),
|
|
109
|
+
_inp(
|
|
110
|
+
"surfaces",
|
|
111
|
+
"Surfaces",
|
|
112
|
+
_OT.OBJECT_TYPE_SURFACE,
|
|
113
|
+
rfem.structure_core.Surface,
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
Group(
|
|
118
|
+
"Types for Nodes",
|
|
119
|
+
tables=(
|
|
120
|
+
_inp(
|
|
121
|
+
"nodal_supports",
|
|
122
|
+
"Nodal Supports",
|
|
123
|
+
_OT.OBJECT_TYPE_NODAL_SUPPORT,
|
|
124
|
+
rfem.types_for_nodes.NodalSupport,
|
|
125
|
+
),
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
Group(
|
|
129
|
+
"Types for Members",
|
|
130
|
+
tables=(
|
|
131
|
+
_inp(
|
|
132
|
+
"member_hinges",
|
|
133
|
+
"Member Hinges",
|
|
134
|
+
_OT.OBJECT_TYPE_MEMBER_HINGE,
|
|
135
|
+
rfem.types_for_members.MemberHinge,
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
Group(
|
|
142
|
+
"Load Cases and Combinations",
|
|
143
|
+
tables=(
|
|
144
|
+
_inp(
|
|
145
|
+
"load_cases",
|
|
146
|
+
"Load Cases",
|
|
147
|
+
_OT.OBJECT_TYPE_LOAD_CASE,
|
|
148
|
+
rfem.loading.LoadCase,
|
|
149
|
+
),
|
|
150
|
+
_inp(
|
|
151
|
+
"load_combinations",
|
|
152
|
+
"Load Combinations",
|
|
153
|
+
_OT.OBJECT_TYPE_LOAD_COMBINATION,
|
|
154
|
+
rfem.loading.LoadCombination,
|
|
155
|
+
),
|
|
156
|
+
_inp(
|
|
157
|
+
"result_combinations",
|
|
158
|
+
"Result Combinations",
|
|
159
|
+
_OT.OBJECT_TYPE_RESULT_COMBINATION,
|
|
160
|
+
rfem.loading.ResultCombination,
|
|
161
|
+
),
|
|
162
|
+
),
|
|
163
|
+
),
|
|
164
|
+
Group(
|
|
165
|
+
"Loads",
|
|
166
|
+
tables=(
|
|
167
|
+
_inp(
|
|
168
|
+
"nodal_loads",
|
|
169
|
+
"Nodal Loads",
|
|
170
|
+
_OT.OBJECT_TYPE_NODAL_LOAD,
|
|
171
|
+
rfem.loads.NodalLoad,
|
|
172
|
+
),
|
|
173
|
+
_inp(
|
|
174
|
+
"member_loads",
|
|
175
|
+
"Member Loads",
|
|
176
|
+
_OT.OBJECT_TYPE_MEMBER_LOAD,
|
|
177
|
+
rfem.loads.MemberLoad,
|
|
178
|
+
),
|
|
179
|
+
_inp(
|
|
180
|
+
"surface_loads",
|
|
181
|
+
"Surface Loads",
|
|
182
|
+
_OT.OBJECT_TYPE_SURFACE_LOAD,
|
|
183
|
+
rfem.loads.SurfaceLoad,
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
Group(
|
|
188
|
+
"Results",
|
|
189
|
+
subgroups=(
|
|
190
|
+
Group(
|
|
191
|
+
"Nodes",
|
|
192
|
+
tables=(
|
|
193
|
+
_res(
|
|
194
|
+
"res_node_support_forces",
|
|
195
|
+
"Nodal Support Forces",
|
|
196
|
+
_RT.STATIC_ANALYSIS_NODES_SUPPORT_FORCES_TABLE,
|
|
197
|
+
),
|
|
198
|
+
_res(
|
|
199
|
+
"res_node_deformations",
|
|
200
|
+
"Nodal Deformations",
|
|
201
|
+
_RT.STATIC_ANALYSIS_NODES_GLOBAL_DEFORMATIONS_TABLE,
|
|
202
|
+
),
|
|
203
|
+
),
|
|
204
|
+
),
|
|
205
|
+
Group(
|
|
206
|
+
"Members",
|
|
207
|
+
tables=(
|
|
208
|
+
_res(
|
|
209
|
+
"res_member_internal_forces",
|
|
210
|
+
"Member Internal Forces",
|
|
211
|
+
_RT.STATIC_ANALYSIS_MEMBERS_INTERNAL_FORCES_TABLE,
|
|
212
|
+
),
|
|
213
|
+
_res(
|
|
214
|
+
"res_member_global_def",
|
|
215
|
+
"Member Global Deformations",
|
|
216
|
+
_RT.STATIC_ANALYSIS_MEMBERS_GLOBAL_DEFORMATIONS_TABLE,
|
|
217
|
+
),
|
|
218
|
+
_res(
|
|
219
|
+
"res_member_local_def",
|
|
220
|
+
"Member Local Deformations",
|
|
221
|
+
_RT.STATIC_ANALYSIS_MEMBERS_LOCAL_DEFORMATIONS_TABLE,
|
|
222
|
+
),
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
Group(
|
|
226
|
+
"Lines",
|
|
227
|
+
tables=(
|
|
228
|
+
_res(
|
|
229
|
+
"res_line_support_forces",
|
|
230
|
+
"Line Support Forces",
|
|
231
|
+
_RT.STATIC_ANALYSIS_LINES_SUPPORT_FORCES_TABLE,
|
|
232
|
+
),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
Group(
|
|
236
|
+
"Surfaces",
|
|
237
|
+
tables=(
|
|
238
|
+
_res(
|
|
239
|
+
"res_surface_internal_forces",
|
|
240
|
+
"Surface Internal Forces",
|
|
241
|
+
_RT.STATIC_ANALYSIS_SURFACES_BASIC_INTERNAL_FORCES_MESH_NODES_TABLE,
|
|
242
|
+
),
|
|
243
|
+
),
|
|
244
|
+
),
|
|
245
|
+
),
|
|
246
|
+
),
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --------------------------------------------------------------------------
|
|
251
|
+
# Traversal helpers
|
|
252
|
+
# --------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def iter_tables(groups: tuple[Group, ...] = TREE):
|
|
256
|
+
"""Yield every TableDef in tree (display) order."""
|
|
257
|
+
for group in groups:
|
|
258
|
+
yield from group.tables
|
|
259
|
+
yield from iter_tables(group.subgroups)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
_BY_KEY: dict[str, TableDef] = {t.key: t for t in iter_tables()}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def get_table(key: str) -> TableDef:
|
|
266
|
+
return _BY_KEY[key]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""RFEM connection, model identification, and unit-summary helpers.
|
|
2
|
+
|
|
3
|
+
This is the single source of truth for attaching to a running RFEM 6 process.
|
|
4
|
+
The API key is resolved from (in order): explicit argument, the RFEM_API_KEY
|
|
5
|
+
environment variable, then Dlubal's bundled ``config.ini``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import configparser
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
# --- Dlubal bundled Python path -------------------------------------------
|
|
17
|
+
# When this app runs under an interpreter that doesn't already have the dlubal
|
|
18
|
+
# package importable, inject Dlubal's site-packages. Harmless if already present.
|
|
19
|
+
_DLUBAL_SITE_CANDIDATES = [
|
|
20
|
+
r"C:\Program Files\Dlubal\RFEM 6.14\bin\Lib\site-packages",
|
|
21
|
+
r"C:\Program Files\Dlubal\RFEM 6.13\bin\Lib\site-packages",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _ensure_dlubal_on_path() -> None:
|
|
26
|
+
try:
|
|
27
|
+
import dlubal.api.rfem # noqa: F401
|
|
28
|
+
|
|
29
|
+
return
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
for candidate in _DLUBAL_SITE_CANDIDATES:
|
|
33
|
+
if os.path.isdir(candidate) and candidate not in sys.path:
|
|
34
|
+
sys.path.insert(0, candidate)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
_ensure_dlubal_on_path()
|
|
38
|
+
|
|
39
|
+
import dlubal.api.rfem as rfem # noqa: E402
|
|
40
|
+
|
|
41
|
+
# config.ini lives next to the dlubal api package, regardless of RFEM version.
|
|
42
|
+
_CONFIG_INI = Path(rfem.__file__).resolve().parent.parent / "config.ini"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _config_candidates() -> list[Path]:
|
|
46
|
+
"""All config.ini locations to check, preferring the active package's."""
|
|
47
|
+
candidates = [_CONFIG_INI]
|
|
48
|
+
for site in _DLUBAL_SITE_CANDIDATES:
|
|
49
|
+
candidates.append(Path(site) / "dlubal" / "api" / "config.ini")
|
|
50
|
+
# de-dup, keep order
|
|
51
|
+
seen: set[Path] = set()
|
|
52
|
+
out: list[Path] = []
|
|
53
|
+
for c in candidates:
|
|
54
|
+
if c not in seen:
|
|
55
|
+
seen.add(c)
|
|
56
|
+
out.append(c)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_api_key_from_config(config_path: Path | None = None) -> str | None:
|
|
61
|
+
"""Read the 'default' API key from Dlubal's config.ini.
|
|
62
|
+
|
|
63
|
+
If ``config_path`` is given, only that file is checked. Otherwise every
|
|
64
|
+
known Dlubal install's config.ini is scanned (the key is version-specific
|
|
65
|
+
and may live under a different RFEM version than the active package).
|
|
66
|
+
"""
|
|
67
|
+
paths = [config_path] if config_path is not None else _config_candidates()
|
|
68
|
+
for path in paths:
|
|
69
|
+
cfg = configparser.ConfigParser()
|
|
70
|
+
cfg.read(path)
|
|
71
|
+
key = cfg.get("api_keys", "default", fallback=None)
|
|
72
|
+
if key:
|
|
73
|
+
return key
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_api_key(explicit: str | None = None) -> str | None:
|
|
78
|
+
"""Return API key from explicit arg, env var, or config.ini — in that order."""
|
|
79
|
+
if explicit:
|
|
80
|
+
return explicit
|
|
81
|
+
env = os.environ.get("RFEM_API_KEY")
|
|
82
|
+
if env:
|
|
83
|
+
return env
|
|
84
|
+
return read_api_key_from_config()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class ModelInfo:
|
|
89
|
+
name: str = ""
|
|
90
|
+
path: str = ""
|
|
91
|
+
guid: str = ""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class UnitSummary:
|
|
96
|
+
"""A flat label->value view of the model's unit/format settings."""
|
|
97
|
+
|
|
98
|
+
system: str = "model" # we always export in the model's configured units
|
|
99
|
+
details: dict[str, str] = field(default_factory=dict)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def connect(api_key: str | None):
|
|
103
|
+
"""Open a long-lived RFEM Application bound to the active model.
|
|
104
|
+
|
|
105
|
+
The caller owns the lifecycle; close it with ``app.close_connection()`` or
|
|
106
|
+
use it as a context manager. Raises on connection failure.
|
|
107
|
+
"""
|
|
108
|
+
return rfem.Application(api_key_value=api_key)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_model_info(app) -> ModelInfo:
|
|
112
|
+
"""Return name/path/guid of the active model.
|
|
113
|
+
|
|
114
|
+
``get_model_list().model_info`` has been observed both as a repeated field
|
|
115
|
+
(indexable) and as a singular message, so we handle both shapes.
|
|
116
|
+
"""
|
|
117
|
+
models = app.get_model_list()
|
|
118
|
+
mi = models.model_info
|
|
119
|
+
# repeated/indexable shape
|
|
120
|
+
try:
|
|
121
|
+
first = mi[0]
|
|
122
|
+
except (TypeError, KeyError, IndexError):
|
|
123
|
+
first = mi # singular message
|
|
124
|
+
return ModelInfo(
|
|
125
|
+
name=getattr(first, "name", "") or "",
|
|
126
|
+
path=getattr(first, "path", "") or "",
|
|
127
|
+
guid=getattr(first, "guid", "") or "",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_unit_summary(app) -> UnitSummary:
|
|
132
|
+
"""Describe the units regime for the info sheet.
|
|
133
|
+
|
|
134
|
+
v1 does not convert units — every value is written exactly as the RFEM API
|
|
135
|
+
returns it, i.e. in the model's own configured units. RFEM does not expose a
|
|
136
|
+
unit table via BaseData, so this is a descriptive label plus a couple of
|
|
137
|
+
informational settings when available.
|
|
138
|
+
"""
|
|
139
|
+
summary = UnitSummary(system="Model units (no conversion applied)")
|
|
140
|
+
try:
|
|
141
|
+
gs = app.get_base_data().general_settings
|
|
142
|
+
accel = getattr(gs, "gravitational_acceleration", None)
|
|
143
|
+
if accel:
|
|
144
|
+
summary.details["gravitational_acceleration"] = str(accel)
|
|
145
|
+
except Exception:
|
|
146
|
+
pass
|
|
147
|
+
return summary
|
rfem_export/excel.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Workbook writer: one sheet per exported table plus an info sheet.
|
|
2
|
+
|
|
3
|
+
Always writes a new timestamped file under ``results/``. Sheet formatting
|
|
4
|
+
(frozen header, auto-sized columns, named Excel Table) generalizes the pattern
|
|
5
|
+
from the original export_reactions.py proof-of-concept.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from openpyxl import Workbook
|
|
17
|
+
from openpyxl.utils import get_column_letter
|
|
18
|
+
from openpyxl.worksheet.table import Table as XlTable
|
|
19
|
+
from openpyxl.worksheet.table import TableStyleInfo
|
|
20
|
+
|
|
21
|
+
from .connection import ModelInfo, UnitSummary
|
|
22
|
+
|
|
23
|
+
OUTPUT_DIR = Path(__file__).resolve().parent.parent / "results"
|
|
24
|
+
|
|
25
|
+
_INVALID_SHEET_CHARS = re.compile(r"[\[\]\:\*\?\/\\]")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class SheetReport:
|
|
30
|
+
table_name: str
|
|
31
|
+
sheet_name: str
|
|
32
|
+
rows: int
|
|
33
|
+
warnings: list[str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _safe_filename(model_name: str) -> str:
|
|
37
|
+
cleaned = "".join(c if c.isalnum() or c in " ._-" else "_" for c in model_name)
|
|
38
|
+
return cleaned or "model"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sheet_name(raw: str, used: set[str]) -> str:
|
|
42
|
+
"""Sanitize to Excel's rules (<=31 chars, no special chars, unique)."""
|
|
43
|
+
name = _INVALID_SHEET_CHARS.sub(" ", raw).strip() or "Sheet"
|
|
44
|
+
name = name[:31]
|
|
45
|
+
base = name
|
|
46
|
+
i = 2
|
|
47
|
+
while name.lower() in used:
|
|
48
|
+
suffix = f" ({i})"
|
|
49
|
+
name = base[: 31 - len(suffix)] + suffix
|
|
50
|
+
i += 1
|
|
51
|
+
used.add(name.lower())
|
|
52
|
+
return name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _style_sheet(ws, table_display_name: str) -> None:
|
|
56
|
+
if ws.max_row < 1:
|
|
57
|
+
return
|
|
58
|
+
ws.freeze_panes = "A2"
|
|
59
|
+
for col_idx, col_cells in enumerate(ws.columns, start=1):
|
|
60
|
+
max_len = max(
|
|
61
|
+
(len(str(cell.value)) if cell.value is not None else 0)
|
|
62
|
+
for cell in col_cells
|
|
63
|
+
)
|
|
64
|
+
ws.column_dimensions[get_column_letter(col_idx)].width = min(max_len + 2, 40)
|
|
65
|
+
|
|
66
|
+
# Named Excel Table (only valid when there's at least a header + the table
|
|
67
|
+
# has columns). Display name must be unique and contain no spaces.
|
|
68
|
+
if ws.max_column >= 1 and ws.max_row >= 1:
|
|
69
|
+
ref = f"A1:{get_column_letter(ws.max_column)}{ws.max_row}"
|
|
70
|
+
disp = re.sub(r"\W", "_", table_display_name) or "Table"
|
|
71
|
+
disp = f"T_{disp}"[:255]
|
|
72
|
+
try:
|
|
73
|
+
xl = XlTable(displayName=disp, ref=ref)
|
|
74
|
+
xl.tableStyleInfo = TableStyleInfo(
|
|
75
|
+
name="TableStyleMedium9",
|
|
76
|
+
showRowStripes=True,
|
|
77
|
+
)
|
|
78
|
+
ws.add_table(xl)
|
|
79
|
+
except Exception:
|
|
80
|
+
pass # styling is best-effort; data is what matters
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write_dataframe(ws, df: pd.DataFrame) -> None:
|
|
84
|
+
if df is None or df.empty:
|
|
85
|
+
ws.append(["(no data)"])
|
|
86
|
+
return
|
|
87
|
+
ws.append([str(c) for c in df.columns])
|
|
88
|
+
for row in df.itertuples(index=False, name=None):
|
|
89
|
+
ws.append([_cell(v) for v in row])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cell(v):
|
|
93
|
+
if v is None:
|
|
94
|
+
return None
|
|
95
|
+
if isinstance(v, (int, float, str, bool)):
|
|
96
|
+
return v
|
|
97
|
+
return str(v)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def write_workbook(
|
|
101
|
+
*,
|
|
102
|
+
model: ModelInfo,
|
|
103
|
+
units: UnitSummary,
|
|
104
|
+
scope: str,
|
|
105
|
+
sheets: list[tuple[str, pd.DataFrame]],
|
|
106
|
+
reports: list[SheetReport],
|
|
107
|
+
output_dir: Path = OUTPUT_DIR,
|
|
108
|
+
) -> Path:
|
|
109
|
+
"""Write all sheets + an info sheet to a new timestamped workbook."""
|
|
110
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
112
|
+
filename = output_dir / f"rfem_export_{_safe_filename(model.name)}_{timestamp}.xlsx"
|
|
113
|
+
|
|
114
|
+
wb = Workbook()
|
|
115
|
+
# First sheet is the info sheet.
|
|
116
|
+
info_ws = wb.active
|
|
117
|
+
info_ws.title = "Export Info"
|
|
118
|
+
_write_info_sheet(info_ws, model, units, scope, reports, timestamp)
|
|
119
|
+
|
|
120
|
+
used = {"export info"}
|
|
121
|
+
for table_display_name, df in sheets:
|
|
122
|
+
ws = wb.create_sheet(title=_sheet_name(table_display_name, used))
|
|
123
|
+
_write_dataframe(ws, df)
|
|
124
|
+
_style_sheet(ws, table_display_name)
|
|
125
|
+
|
|
126
|
+
wb.save(filename)
|
|
127
|
+
return filename
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _write_info_sheet(
|
|
131
|
+
ws,
|
|
132
|
+
model: ModelInfo,
|
|
133
|
+
units: UnitSummary,
|
|
134
|
+
scope: str,
|
|
135
|
+
reports: list[SheetReport],
|
|
136
|
+
timestamp: str,
|
|
137
|
+
) -> None:
|
|
138
|
+
ws.append(["RFEM Table Export"])
|
|
139
|
+
ws.append([])
|
|
140
|
+
ws.append(["Model name", model.name])
|
|
141
|
+
ws.append(["Model path", model.path])
|
|
142
|
+
ws.append(["Model GUID", model.guid])
|
|
143
|
+
ws.append(["Exported at", timestamp])
|
|
144
|
+
ws.append(["Scope", scope])
|
|
145
|
+
ws.append(["Unit system", units.system])
|
|
146
|
+
if units.details:
|
|
147
|
+
ws.append([])
|
|
148
|
+
ws.append(["Unit settings"])
|
|
149
|
+
for k, v in units.details.items():
|
|
150
|
+
ws.append([k, v])
|
|
151
|
+
|
|
152
|
+
ws.append([])
|
|
153
|
+
ws.append(["Exported tables"])
|
|
154
|
+
ws.append(["Table", "Sheet", "Rows", "Warnings"])
|
|
155
|
+
for r in reports:
|
|
156
|
+
ws.append([r.table_name, r.sheet_name, r.rows, " | ".join(r.warnings)])
|
|
157
|
+
|
|
158
|
+
ws.column_dimensions["A"].width = 22
|
|
159
|
+
ws.column_dimensions["B"].width = 30
|
|
160
|
+
ws.column_dimensions["C"].width = 10
|
|
161
|
+
ws.column_dimensions["D"].width = 80
|
rfem_export/extract.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Extraction of input objects and result tables into pandas DataFrames.
|
|
2
|
+
|
|
3
|
+
Every extractor returns an ``ExtractResult`` carrying the DataFrame plus a list
|
|
4
|
+
of human-readable warnings, so a single problematic table can never abort the
|
|
5
|
+
whole export.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from google.protobuf.json_format import MessageToDict
|
|
14
|
+
|
|
15
|
+
# Import order matters: importing ``connection`` injects Dlubal's bundled
|
|
16
|
+
# site-packages onto sys.path, which must happen before ``dlubal.api.rfem`` is
|
|
17
|
+
# imported. Do not let isort reorder these (see per-file-ignores in pyproject).
|
|
18
|
+
from . import connection # noqa: F401 (side effect: makes dlubal importable)
|
|
19
|
+
import dlubal.api.rfem as rfem # noqa: E402
|
|
20
|
+
|
|
21
|
+
from .catalog import TableDef
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ExtractResult:
|
|
26
|
+
df: pd.DataFrame = field(default_factory=pd.DataFrame)
|
|
27
|
+
warnings: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def rows(self) -> int:
|
|
31
|
+
return 0 if self.df is None else len(self.df)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------
|
|
35
|
+
# Input objects
|
|
36
|
+
# --------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _object_ids(app, object_type) -> list[int]:
|
|
40
|
+
id_list = app.get_object_id_list(object_type=object_type)
|
|
41
|
+
return [oid.no for oid in id_list.object_id]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Some objects (notably loads) are children of another object and cannot be
|
|
45
|
+
# read without naming that parent on the proto. Map the parent's ObjectType to
|
|
46
|
+
# the proto field that addresses it.
|
|
47
|
+
_PARENT_FIELD_BY_TYPE = {
|
|
48
|
+
rfem.ObjectType.OBJECT_TYPE_LOAD_CASE: "load_case",
|
|
49
|
+
rfem.ObjectType.OBJECT_TYPE_LOAD_COMBINATION: "load_combination",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _object_id_entries(app, object_type) -> list:
|
|
54
|
+
"""Return the full object_id messages (carry no + parent info)."""
|
|
55
|
+
id_list = app.get_object_id_list(object_type=object_type)
|
|
56
|
+
return list(id_list.object_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _build_proto(proto, oid):
|
|
60
|
+
"""Construct a proto for get_object, setting any required parent field."""
|
|
61
|
+
kwargs = {"no": oid.no}
|
|
62
|
+
parent_no = getattr(oid, "parent_no", 0)
|
|
63
|
+
if parent_no:
|
|
64
|
+
field = _PARENT_FIELD_BY_TYPE.get(getattr(oid, "parent_object_type", None))
|
|
65
|
+
# Only set the parent field if the proto actually declares it.
|
|
66
|
+
if field and field in {f.name for f in proto.DESCRIPTOR.fields}:
|
|
67
|
+
kwargs[field] = parent_no
|
|
68
|
+
return proto(**kwargs)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _proto_to_row(msg) -> dict:
|
|
72
|
+
"""Flatten a protobuf message to a single flat dict row."""
|
|
73
|
+
d = MessageToDict(
|
|
74
|
+
msg,
|
|
75
|
+
preserving_proto_field_name=True,
|
|
76
|
+
use_integers_for_enums=False,
|
|
77
|
+
)
|
|
78
|
+
return d
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def extract_input(app, table: TableDef) -> ExtractResult:
|
|
82
|
+
res = ExtractResult()
|
|
83
|
+
try:
|
|
84
|
+
entries = _object_id_entries(app, table.object_type)
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
res.warnings.append(f"{table.name}: could not list object IDs — {exc}")
|
|
87
|
+
return res
|
|
88
|
+
|
|
89
|
+
if not entries:
|
|
90
|
+
res.warnings.append(f"{table.name}: no objects of this type in the model")
|
|
91
|
+
return res
|
|
92
|
+
|
|
93
|
+
rows: list[dict] = []
|
|
94
|
+
failures = 0
|
|
95
|
+
last_error = ""
|
|
96
|
+
for oid in entries:
|
|
97
|
+
try:
|
|
98
|
+
obj = app.get_object(_build_proto(table.proto, oid))
|
|
99
|
+
rows.append(_proto_to_row(obj))
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
failures += 1
|
|
102
|
+
last_error = (
|
|
103
|
+
str(exc).splitlines()[0] if str(exc) else exc.__class__.__name__
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if failures:
|
|
107
|
+
res.warnings.append(
|
|
108
|
+
f"{table.name}: {failures} of {len(entries)} object(s) could not be read"
|
|
109
|
+
+ (f" — {last_error}" if last_error else "")
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if not rows:
|
|
113
|
+
res.warnings.append(f"{table.name}: no readable objects")
|
|
114
|
+
return res
|
|
115
|
+
|
|
116
|
+
res.df = pd.json_normalize(rows)
|
|
117
|
+
return res
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --------------------------------------------------------------------------
|
|
121
|
+
# Result tables (iterated over every loading)
|
|
122
|
+
# --------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
_LOADING_SPECS = [
|
|
125
|
+
("Load Case", rfem.ObjectType.OBJECT_TYPE_LOAD_CASE, rfem.loading.LoadCase),
|
|
126
|
+
(
|
|
127
|
+
"Load Combination",
|
|
128
|
+
rfem.ObjectType.OBJECT_TYPE_LOAD_COMBINATION,
|
|
129
|
+
rfem.loading.LoadCombination,
|
|
130
|
+
),
|
|
131
|
+
(
|
|
132
|
+
"Result Combination",
|
|
133
|
+
rfem.ObjectType.OBJECT_TYPE_RESULT_COMBINATION,
|
|
134
|
+
rfem.loading.ResultCombination,
|
|
135
|
+
),
|
|
136
|
+
]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _list_loadings(app) -> list[tuple[str, int, str, object]]:
|
|
140
|
+
"""Return (label, no, name, object_type) for every load case/combination."""
|
|
141
|
+
out: list[tuple[str, int, str, object]] = []
|
|
142
|
+
for label, obj_type, proto in _LOADING_SPECS:
|
|
143
|
+
try:
|
|
144
|
+
ids = _object_ids(app, obj_type)
|
|
145
|
+
except Exception:
|
|
146
|
+
continue
|
|
147
|
+
for no in ids:
|
|
148
|
+
try:
|
|
149
|
+
obj = app.get_object(proto(no=no))
|
|
150
|
+
name = getattr(obj, "name", "") or ""
|
|
151
|
+
except Exception:
|
|
152
|
+
name = ""
|
|
153
|
+
out.append((label, no, name, obj_type))
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def extract_result(app, table: TableDef) -> ExtractResult:
|
|
158
|
+
res = ExtractResult()
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
hr = app.has_results()
|
|
162
|
+
has = getattr(hr, "value", hr) # has_results() returns a BoolValue wrapper
|
|
163
|
+
if not has:
|
|
164
|
+
res.warnings.append(f"{table.name}: model has no analysis results")
|
|
165
|
+
return res
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
res.warnings.append(f"{table.name}: has_results() failed — {exc}")
|
|
168
|
+
return res
|
|
169
|
+
|
|
170
|
+
loadings = _list_loadings(app)
|
|
171
|
+
if not loadings:
|
|
172
|
+
res.warnings.append(f"{table.name}: no load cases or combinations found")
|
|
173
|
+
return res
|
|
174
|
+
|
|
175
|
+
frames: list[pd.DataFrame] = []
|
|
176
|
+
for loading_type, no, name, obj_type in loadings:
|
|
177
|
+
label = f"{loading_type} {no} ({name})"
|
|
178
|
+
try:
|
|
179
|
+
loading_id = rfem.ObjectId(no=no, object_type=obj_type)
|
|
180
|
+
result_table = app.get_result_table(table.result_table, loading_id)
|
|
181
|
+
data = getattr(result_table, "data", None)
|
|
182
|
+
if data is None or len(data) == 0:
|
|
183
|
+
continue
|
|
184
|
+
df = data.copy()
|
|
185
|
+
df.insert(0, "loading_type", loading_type)
|
|
186
|
+
df.insert(1, "loading_no", no)
|
|
187
|
+
df.insert(2, "loading_name", name)
|
|
188
|
+
frames.append(df)
|
|
189
|
+
except Exception as exc:
|
|
190
|
+
res.warnings.append(f"{table.name}: {label} skipped — {exc}")
|
|
191
|
+
|
|
192
|
+
if not frames:
|
|
193
|
+
res.warnings.append(f"{table.name}: no result rows across any loading")
|
|
194
|
+
return res
|
|
195
|
+
|
|
196
|
+
res.df = pd.concat(frames, ignore_index=True)
|
|
197
|
+
return res
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def extract(app, table: TableDef) -> ExtractResult:
|
|
201
|
+
if table.kind == "input":
|
|
202
|
+
return extract_input(app, table)
|
|
203
|
+
if table.kind == "result":
|
|
204
|
+
return extract_result(app, table)
|
|
205
|
+
return ExtractResult(warnings=[f"{table.name}: unknown table kind '{table.kind}'"])
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rfem-table-export
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Terminal UI for exporting RFEM 6 input and result tables to Excel
|
|
5
|
+
Project-URL: Homepage, https://github.com/Mark-Milkis/rfem-table-export
|
|
6
|
+
Project-URL: Repository, https://github.com/Mark-Milkis/rfem-table-export
|
|
7
|
+
Project-URL: Issues, https://github.com/Mark-Milkis/rfem-table-export/issues
|
|
8
|
+
Author-email: Mark Milkis <markmilkis@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dlubal,engineering,excel,export,rfem,structural,tui
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: dlubal-api==2.14.3
|
|
23
|
+
Requires-Dist: openpyxl>=3.1
|
|
24
|
+
Requires-Dist: pandas>=2.0
|
|
25
|
+
Requires-Dist: textual>=0.80
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# rfem-table-export
|
|
29
|
+
|
|
30
|
+
A terminal UI (TUI) for exporting [RFEM 6](https://www.dlubal.com/en/products/rfem-fea-software/what-is-rfem)
|
|
31
|
+
tables — both model **inputs** and analysis **results** — to a formatted Excel
|
|
32
|
+
workbook. It attaches to an already-running RFEM 6 process over the Dlubal gRPC
|
|
33
|
+
API, lets you confirm the active model, pick tables from a tree that mirrors the
|
|
34
|
+
RFEM GUI navigators, and writes a new timestamped `.xlsx` with one sheet per
|
|
35
|
+
table plus an info sheet capturing export metadata and any warnings.
|
|
36
|
+
|
|
37
|
+
The table-picker UX is modeled on SAP2000's "Export Tables to Excel" dialog.
|
|
38
|
+
|
|
39
|
+
## Features
|
|
40
|
+
|
|
41
|
+
- **Attach to a live model** — connects to the running RFEM 6 process and shows
|
|
42
|
+
the active model's name and path so you can verify you're exporting the right
|
|
43
|
+
one before you start.
|
|
44
|
+
- **Navigator-style table tree** — tables are organized exactly like the RFEM
|
|
45
|
+
GUI (Structure → Basic Objects → Nodes, …; Load Cases and Combinations; Loads;
|
|
46
|
+
Results → Nodes/Members/Lines/Surfaces).
|
|
47
|
+
- **Inputs and results** — model objects (nodes, members, surfaces, materials,
|
|
48
|
+
cross-sections, supports, hinges, load cases/combinations, loads) and static
|
|
49
|
+
result tables (support forces, internal forces, deformations) iterated across
|
|
50
|
+
every load case and combination.
|
|
51
|
+
- **Model units, no surprises** — values are written exactly as the RFEM API
|
|
52
|
+
returns them (the model's own units); the info sheet records this.
|
|
53
|
+
- **One workbook per export** — a new file is always written to `results/`;
|
|
54
|
+
nothing is overwritten.
|
|
55
|
+
- **Resilient** — a single problematic table produces a warning on the info
|
|
56
|
+
sheet rather than aborting the whole export.
|
|
57
|
+
|
|
58
|
+
## Requirements
|
|
59
|
+
|
|
60
|
+
- Windows with **RFEM 6.14** installed and running, a model open, and the gRPC
|
|
61
|
+
web service / API enabled.
|
|
62
|
+
- A valid Dlubal API key (see [Configuration](#configuration)).
|
|
63
|
+
- Python 3.11+.
|
|
64
|
+
|
|
65
|
+
> **Version pin:** the `dlubal-api` client version must match the RFEM server
|
|
66
|
+
> version. This project pins `dlubal-api==2.14.3` for **RFEM 6.14**. If you run a
|
|
67
|
+
> different RFEM version, change the pin to match — mismatched versions cause
|
|
68
|
+
> object-type enum drift and silently wrong/empty exports.
|
|
69
|
+
|
|
70
|
+
## Installation
|
|
71
|
+
|
|
72
|
+
### Run without installing (recommended)
|
|
73
|
+
|
|
74
|
+
With [uv](https://docs.astral.sh/uv/) you can run the latest published release
|
|
75
|
+
directly — no clone, no virtualenv:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
uvx rfem-table-export
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Pin a specific version (handy for matching your RFEM/`dlubal-api` version):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
uvx rfem-table-export@0.2.0
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
> The console command is also available as `rfem-export`; with `uvx` use the
|
|
88
|
+
> distribution name (`rfem-table-export`) so version pinning works.
|
|
89
|
+
|
|
90
|
+
### From a clone (for development)
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
uv sync
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Or with pip:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install -e .
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Usage
|
|
103
|
+
|
|
104
|
+
1. Start RFEM 6, open and (for result tables) solve your model.
|
|
105
|
+
2. Launch the TUI:
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
uv run rfem-export
|
|
109
|
+
# or
|
|
110
|
+
python -m rfem_export.app
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
3. Confirm the banner shows the correct active model.
|
|
114
|
+
4. Navigate the tree and toggle the tables you want:
|
|
115
|
+
|
|
116
|
+
| Key | Action |
|
|
117
|
+
|---------|-------------------|
|
|
118
|
+
| `space` | Toggle table |
|
|
119
|
+
| `a` | Select all |
|
|
120
|
+
| `n` | Clear selection |
|
|
121
|
+
| `e` | Export |
|
|
122
|
+
| `r` | Reconnect to RFEM |
|
|
123
|
+
| `q` | Quit |
|
|
124
|
+
|
|
125
|
+
5. Press `e`. The workbook is written to `results/rfem_export_<model>_<timestamp>.xlsx`.
|
|
126
|
+
|
|
127
|
+
## Configuration
|
|
128
|
+
|
|
129
|
+
The API key is resolved in this order:
|
|
130
|
+
|
|
131
|
+
1. `--api-key` command-line argument
|
|
132
|
+
2. `RFEM_API_KEY` environment variable
|
|
133
|
+
3. Dlubal's bundled `config.ini` (`[api_keys] default = …`), scanned across
|
|
134
|
+
installed RFEM versions
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
uv run rfem-export --api-key YOUR_KEY
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Project layout
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
rfem_export/
|
|
144
|
+
__init__.py
|
|
145
|
+
connection.py # API-key resolution, attach to RFEM, model info, unit summary
|
|
146
|
+
catalog.py # curated navigator tree of exportable tables (TableDef / Group)
|
|
147
|
+
extract.py # protobuf objects + result tables -> pandas DataFrames
|
|
148
|
+
excel.py # workbook writer: one sheet per table + info sheet
|
|
149
|
+
app.py # Textual TUI + console entry point
|
|
150
|
+
examples/
|
|
151
|
+
export_reactions.py # original single-table proof-of-concept
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Extending table coverage
|
|
155
|
+
|
|
156
|
+
Add a `TableDef` to the `TREE` in [`rfem_export/catalog.py`](rfem_export/catalog.py).
|
|
157
|
+
Both extraction and the TUI pick up new entries automatically — inputs are
|
|
158
|
+
extracted by enumerating object IDs of an `ObjectType`; results via
|
|
159
|
+
`get_result_table` across every loading.
|
|
160
|
+
|
|
161
|
+
## Development
|
|
162
|
+
|
|
163
|
+
Lint and format with [ruff](https://docs.astral.sh/ruff/) (the same checks CI
|
|
164
|
+
runs):
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
uvx ruff check . # lint
|
|
168
|
+
uvx ruff format --check . # formatting
|
|
169
|
+
uvx ruff format . # auto-format
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Versioning & releases
|
|
173
|
+
|
|
174
|
+
The package version is **derived automatically from git tags** via
|
|
175
|
+
[`hatch-vcs`](https://github.com/ofek/hatch-vcs) — there is no hardcoded
|
|
176
|
+
version to bump. Tag `vX.Y.Z` becomes version `X.Y.Z`.
|
|
177
|
+
|
|
178
|
+
To cut a release:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
git tag v0.2.0
|
|
182
|
+
git push origin v0.2.0
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
Pushing the tag triggers the **Release** workflow, which lints, builds the
|
|
186
|
+
sdist/wheel, verifies the built version matches the tag, publishes to
|
|
187
|
+
[PyPI](https://pypi.org/p/rfem-table-export), and creates a GitHub release.
|
|
188
|
+
|
|
189
|
+
> **One-time PyPI setup:** publishing uses
|
|
190
|
+
> [Trusted Publishing (OIDC)](https://docs.pypi.org/trusted-publishers/), so no
|
|
191
|
+
> API token is stored. On PyPI, add a trusted publisher for this project with:
|
|
192
|
+
> owner `Mark-Milkis`, repository `rfem-table-export`, workflow
|
|
193
|
+
> `release.yml`, and environment `pypi`. (For the very first publish you may
|
|
194
|
+
> need to use the "pending publisher" flow since the project doesn't exist on
|
|
195
|
+
> PyPI yet.)
|
|
196
|
+
|
|
197
|
+
## License
|
|
198
|
+
|
|
199
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
rfem_export/__init__.py,sha256=WJpWLZl_t-PapyWMeoA48U5IhJX9tra33ZRaC4MblBE,715
|
|
2
|
+
rfem_export/_version.py,sha256=s9U3X54Pdr-Jlh9GP6LBaa_VRos7qs_wtrVefUHHNIA,520
|
|
3
|
+
rfem_export/app.py,sha256=1cXsU3SPhTY5NO5c6t8w_2kJGLgXEO9c6UXe4FDsN4E,9283
|
|
4
|
+
rfem_export/catalog.py,sha256=iQepQBZ6ifmQrDwqHYcm-lhcvZ1EOdRerZ1dbfqCaBI,8379
|
|
5
|
+
rfem_export/connection.py,sha256=9y60AU-5OLNDkK7rXlqlzPyj0JuNSwwtOMZUBIGvotQ,4759
|
|
6
|
+
rfem_export/excel.py,sha256=IGidIH18Sgl1nYmJDW6uVZ4AVpDInS9P526dz0GfSaU,4893
|
|
7
|
+
rfem_export/extract.py,sha256=ShqExN2WNNhpTPbAuScbdooJBHOu7u99NX1Lz5JmdYs,6846
|
|
8
|
+
rfem_table_export-0.2.0.dist-info/METADATA,sha256=Gs_1DTMlrrWW_PtPhEDd8_aj8JRhpsqBp7V0LxLdYLw,6804
|
|
9
|
+
rfem_table_export-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
rfem_table_export-0.2.0.dist-info/entry_points.txt,sha256=av0dTvJqoTQkzCNQCD0O1K9tY-s913MwcE5b4rNtgX4,94
|
|
11
|
+
rfem_table_export-0.2.0.dist-info/licenses/LICENSE,sha256=CQtzw8-Piq2t7Gl6mCWuVeg8fKoNOL57AeHyi6etPfY,1068
|
|
12
|
+
rfem_table_export-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mark Milkis
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|