pynite-tools 0.1.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.
- pynite_tools/__init__.py +39 -0
- pynite_tools/combos.py +27 -0
- pynite_tools/reports.py +616 -0
- pynite_tools/serialize.py +388 -0
- pynite_tools/visualize.py +2162 -0
- pynite_tools-0.1.0.dist-info/METADATA +221 -0
- pynite_tools-0.1.0.dist-info/RECORD +10 -0
- pynite_tools-0.1.0.dist-info/WHEEL +4 -0
- pynite_tools-0.1.0.dist-info/entry_points.txt +3 -0
- pynite_tools-0.1.0.dist-info/licenses/LICENSE +201 -0
pynite_tools/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pynite-tools: A series of functions that enhance the capabilities of PyniteFEA.
|
|
3
|
+
|
|
4
|
+
Modules included:
|
|
5
|
+
|
|
6
|
+
- reports: Functions for bulk exporting structured analysis results
|
|
7
|
+
- serialize: Functions for serializing/deserializing FEModel3D objects
|
|
8
|
+
- loads: Functions for enhancing load assignment to members and nodes
|
|
9
|
+
- combos: Functions for bulk importing load combinations
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
|
13
|
+
|
|
14
|
+
from .reports import (
|
|
15
|
+
extract_node_reactions,
|
|
16
|
+
extract_node_deflections,
|
|
17
|
+
extract_member_arrays,
|
|
18
|
+
extract_member_envelopes,
|
|
19
|
+
extract_member_actions_by_location,
|
|
20
|
+
extract_span_envelopes,
|
|
21
|
+
extract_load_combinations,
|
|
22
|
+
extract_spans,
|
|
23
|
+
merge_result_trees
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .serialize import (
|
|
27
|
+
to_json,
|
|
28
|
+
from_json,
|
|
29
|
+
dump,
|
|
30
|
+
dumps,
|
|
31
|
+
dump_dict,
|
|
32
|
+
load,
|
|
33
|
+
loads,
|
|
34
|
+
load_dict,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from .combos import (
|
|
38
|
+
model_add_combos,
|
|
39
|
+
)
|
pynite_tools/combos.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from copy import deepcopy
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from Pynite import FEModel3D
|
|
4
|
+
|
|
5
|
+
def model_add_combos(load_combos: list[dict[str, dict[str, float]]], model: FEModel3D, as_copy: bool = False) -> Optional[FEModel3D]:
|
|
6
|
+
"""
|
|
7
|
+
Bulk adds a list of load combination dictionaries into the 'model' all at once.
|
|
8
|
+
|
|
9
|
+
'load_combos': a list of load combination elements, each in the following format:
|
|
10
|
+
{
|
|
11
|
+
"name": "load_combo_name",
|
|
12
|
+
"factors": {
|
|
13
|
+
"case_1_name": 1.0,
|
|
14
|
+
"case_2_name": 1.0,
|
|
15
|
+
...
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
'model': A Pynite.FEModel3D
|
|
19
|
+
'as_copy': If True, will return a copy of the FEModel3D with the load combos applied.
|
|
20
|
+
If False, returns None and modifies the FEModel3D in place. Default is False.
|
|
21
|
+
"""
|
|
22
|
+
if as_copy:
|
|
23
|
+
model = deepcopy(model)
|
|
24
|
+
for load_combo in load_combos:
|
|
25
|
+
model.add_load_combo(**load_combo)
|
|
26
|
+
if as_copy:
|
|
27
|
+
return model
|
pynite_tools/reports.py
ADDED
|
@@ -0,0 +1,616 @@
|
|
|
1
|
+
from collections import Counter
|
|
2
|
+
import math
|
|
3
|
+
from typing import Optional, Union, Any
|
|
4
|
+
import numpy as np
|
|
5
|
+
import deepmerge
|
|
6
|
+
import pathlib
|
|
7
|
+
import json
|
|
8
|
+
from Pynite import FEModel3D
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
ACTION_METHODS = {
|
|
12
|
+
"Fy": "shear",
|
|
13
|
+
"Fz": "shear",
|
|
14
|
+
"My": "moment",
|
|
15
|
+
"Mz": "moment",
|
|
16
|
+
"axial": "axial",
|
|
17
|
+
"torque": "torque",
|
|
18
|
+
"dy": "deflection",
|
|
19
|
+
"dz": "deflection",
|
|
20
|
+
"dx": "deflection",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
NODE_REACTIONS = ['FX', 'FY', 'FZ', 'MX', 'MY', 'MZ']
|
|
24
|
+
NODE_DISPLACEMENTS = ['DX', 'DY', 'DZ', 'RX', 'RY', 'RZ']
|
|
25
|
+
|
|
26
|
+
REACTIONS = ['RxnFX', 'RxnFY', 'RxnFZ', 'RxnMX', 'RxnMY', 'RxnMZ']
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_node_reactions(
|
|
30
|
+
model: FEModel3D,
|
|
31
|
+
load_combinations: Optional[list[str]] = None,
|
|
32
|
+
results_key: Optional[str] = "node_reactions",
|
|
33
|
+
) -> dict[str, dict]:
|
|
34
|
+
"""
|
|
35
|
+
Returns all node reactions from 'model'.
|
|
36
|
+
|
|
37
|
+
'model': A solved Pynite.FEModel3D
|
|
38
|
+
'load_combinations': return results only for these combinations.
|
|
39
|
+
When None, returns all combinations. Default is None.
|
|
40
|
+
'results_key': Optional str value used to nest your results
|
|
41
|
+
more descriptively in the tree. Useful when merging
|
|
42
|
+
multiple results trees. Setting to None will make
|
|
43
|
+
your result tree one level shallower.
|
|
44
|
+
"""
|
|
45
|
+
if load_combinations is None:
|
|
46
|
+
load_combinations = extract_load_combinations(model)
|
|
47
|
+
|
|
48
|
+
reaction_results = {}
|
|
49
|
+
# Go through all the nodes...
|
|
50
|
+
for node_name, node in model.nodes.items():
|
|
51
|
+
reaction_results.setdefault(node_name, {})
|
|
52
|
+
# ...and go through all reaction directions...
|
|
53
|
+
inner_acc = reaction_results[node_name]
|
|
54
|
+
if results_key is not None:
|
|
55
|
+
reaction_results[node_name].setdefault(results_key, {})
|
|
56
|
+
inner_acc = reaction_results[node_name][results_key]
|
|
57
|
+
for reaction_dir in NODE_REACTIONS:
|
|
58
|
+
reaction_name = f"Rxn{reaction_dir}"
|
|
59
|
+
# Get the reactions...
|
|
60
|
+
reactions = getattr(node, reaction_name)
|
|
61
|
+
# ...and if the reactions are not all basically 0.0...
|
|
62
|
+
if not (
|
|
63
|
+
all([math.isclose(reaction, 0, abs_tol=1e-8) for reaction in reactions.values()])
|
|
64
|
+
):
|
|
65
|
+
# Then collect them in our analysis results dictionary
|
|
66
|
+
inner_acc[reaction_dir] = {
|
|
67
|
+
lc: round_to_close_integer(reaction)
|
|
68
|
+
for lc, reaction in reactions.items()
|
|
69
|
+
if lc in load_combinations
|
|
70
|
+
}
|
|
71
|
+
# But if any of the nodes in the analysis results dict are empty...
|
|
72
|
+
if inner_acc == {}:
|
|
73
|
+
# ...then drop 'em!
|
|
74
|
+
reaction_results.pop(node_name)
|
|
75
|
+
return reaction_results
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def extract_node_deflections(
|
|
79
|
+
model: FEModel3D,
|
|
80
|
+
load_combinations: Optional[list[str]] = None,
|
|
81
|
+
results_key: Optional[str] = "node_deflections",
|
|
82
|
+
):
|
|
83
|
+
"""
|
|
84
|
+
Returns all node displacements from 'model'.
|
|
85
|
+
|
|
86
|
+
'model': A solved Pynite.FEModel3D
|
|
87
|
+
'load_combinations': return results only for these combinations.
|
|
88
|
+
When None, returns all combinations. Default is None.
|
|
89
|
+
'results_key': Optional str value used to nest your results
|
|
90
|
+
more descriptively in the tree. Useful when merging
|
|
91
|
+
multiple results trees. Setting to None will make
|
|
92
|
+
your result tree one level shallower.
|
|
93
|
+
"""
|
|
94
|
+
if load_combinations is None:
|
|
95
|
+
load_combinations = extract_load_combinations(model)
|
|
96
|
+
node_deflections = {}
|
|
97
|
+
# Go through all the nodes...
|
|
98
|
+
for node_name, node in model.nodes.items():
|
|
99
|
+
node_deflections[node_name] = {}
|
|
100
|
+
|
|
101
|
+
inner_acc = node_deflections[node_name]
|
|
102
|
+
if results_key is not None:
|
|
103
|
+
node_deflections[node_name].setdefault(results_key, {})
|
|
104
|
+
inner_acc = node_deflections[node_name][results_key]
|
|
105
|
+
# ...and go through all deflection directions...
|
|
106
|
+
for defl_dir in NODE_DISPLACEMENTS:
|
|
107
|
+
# Get the deflections...
|
|
108
|
+
deflections = getattr(node, defl_dir)
|
|
109
|
+
# ...and if the deflections are not all basically 0.0...
|
|
110
|
+
if not (
|
|
111
|
+
all([math.isclose(deflection, 0, abs_tol=1e-8) for deflection in deflections.values()])
|
|
112
|
+
):
|
|
113
|
+
# Then collect them in our analysis results dictionary
|
|
114
|
+
inner_acc[defl_dir] = {
|
|
115
|
+
lc: float(defl)
|
|
116
|
+
for lc, defl in deflections.items()
|
|
117
|
+
if lc in load_combinations
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
# But if any of the nodes in the analysis results dict are empty...
|
|
121
|
+
if inner_acc == {}:
|
|
122
|
+
# ...then drop 'em!
|
|
123
|
+
node_deflections.pop(node_name)
|
|
124
|
+
|
|
125
|
+
return node_deflections
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def extract_member_arrays(
|
|
129
|
+
model: FEModel3D,
|
|
130
|
+
n_points: int = 1000,
|
|
131
|
+
as_lists: bool = False,
|
|
132
|
+
results_key: Optional[str] = "action_arrays",
|
|
133
|
+
load_combinations: Optional[list[str]] = None,
|
|
134
|
+
) -> dict[str, dict]:
|
|
135
|
+
"""
|
|
136
|
+
Returns all member action arrays from 'model'. Each array is of
|
|
137
|
+
shape (2, n_points) where the 0th array is the "x_array" providing
|
|
138
|
+
stations along the member and the 1th array is the "y_array"
|
|
139
|
+
providing the analysis results.
|
|
140
|
+
|
|
141
|
+
'model': A solved Pynite.FEModel3D
|
|
142
|
+
'load_combinations': return results only for these combinations.
|
|
143
|
+
When None, returns all combinations. Default is None.
|
|
144
|
+
'n_points': The number of points to use to discretize the array.
|
|
145
|
+
default is 1000.
|
|
146
|
+
'as_lists': If True, then the native numpy arrays returned from
|
|
147
|
+
Pynite will be converted to lists. Useful when serializing
|
|
148
|
+
to JSON.
|
|
149
|
+
'results_key': Optional str value used to nest your results
|
|
150
|
+
more descriptively in the tree. Useful when merging
|
|
151
|
+
multiple results trees. Setting to None will make
|
|
152
|
+
your result tree one level shallower.
|
|
153
|
+
"""
|
|
154
|
+
forces = {}
|
|
155
|
+
# For each member...
|
|
156
|
+
if load_combinations is None:
|
|
157
|
+
load_combinations = extract_load_combinations(model)
|
|
158
|
+
forces = {}
|
|
159
|
+
for member_name, member in model.members.items():
|
|
160
|
+
# For each action...
|
|
161
|
+
forces[member_name] = {}
|
|
162
|
+
inner_acc = forces[member_name]
|
|
163
|
+
if results_key is not None:
|
|
164
|
+
forces[member_name].setdefault(results_key, {})
|
|
165
|
+
inner_acc = forces[member_name][results_key]
|
|
166
|
+
for force_direction, method_type in ACTION_METHODS.items():
|
|
167
|
+
inner_acc.setdefault(force_direction, {})
|
|
168
|
+
array_method = getattr(member, f"{method_type}_array")
|
|
169
|
+
accumulator = inner_acc[force_direction]
|
|
170
|
+
parent_accumulator = inner_acc
|
|
171
|
+
path = force_direction
|
|
172
|
+
|
|
173
|
+
for load_combo_name in load_combinations:
|
|
174
|
+
accumulator.setdefault(load_combo_name, {})
|
|
175
|
+
# hacky fix: AxialDeflection method is receiving None for a .P1, I think (Pynite error to be fixed)
|
|
176
|
+
try:
|
|
177
|
+
if method_type not in ("axial", "torque"):
|
|
178
|
+
result_arrays = array_method(force_direction, combo_name=load_combo_name, n_points=n_points)
|
|
179
|
+
else:
|
|
180
|
+
result_arrays = array_method(combo_name=load_combo_name, n_points=n_points)
|
|
181
|
+
except TypeError:
|
|
182
|
+
parent_accumulator[path] = None
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if (
|
|
186
|
+
(result_arrays.dtype == "object") # An array with None (or full of None_s)
|
|
187
|
+
or
|
|
188
|
+
np.allclose(result_arrays[1], np.zeros(len(result_arrays[1])), atol=1e-8)
|
|
189
|
+
):
|
|
190
|
+
accumulator.pop(load_combo_name)
|
|
191
|
+
pass
|
|
192
|
+
else:
|
|
193
|
+
if as_lists:
|
|
194
|
+
accumulator[load_combo_name] = result_arrays.tolist()
|
|
195
|
+
else:
|
|
196
|
+
accumulator[load_combo_name] = result_arrays
|
|
197
|
+
if not parent_accumulator[path]:
|
|
198
|
+
parent_accumulator.pop(path)
|
|
199
|
+
return forces
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def extract_member_envelopes(
|
|
203
|
+
model: FEModel3D,
|
|
204
|
+
load_combinations: Optional[list[str]] = None,
|
|
205
|
+
results_key: Optional[str] = "action_envelopes"
|
|
206
|
+
) -> dict[str, dict]:
|
|
207
|
+
"""
|
|
208
|
+
Returns member action envelopes for all members in the 'model'.
|
|
209
|
+
Each action ("Fx", "Fy", "Fz", "Mx", "My", "Mz") is enveloped
|
|
210
|
+
for min/max/absmax values. If an action does not have any
|
|
211
|
+
analysis results, then its key is excluded from the result
|
|
212
|
+
tree.
|
|
213
|
+
|
|
214
|
+
'model': A solved Pynite.FEModel3D
|
|
215
|
+
'load_combinations': return results only for these combinations.
|
|
216
|
+
When None, returns all combinations. Default is None.
|
|
217
|
+
'results_key': Optional str value used to nest your results
|
|
218
|
+
more descriptively in the tree. Useful when merging
|
|
219
|
+
multiple results trees. Setting to None will make
|
|
220
|
+
your result tree one level shallower.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
# For each member...
|
|
224
|
+
if load_combinations is None:
|
|
225
|
+
load_combinations = extract_load_combinations(model)
|
|
226
|
+
forces = {}
|
|
227
|
+
for member_name, member in model.members.items():
|
|
228
|
+
forces[member_name] = {}
|
|
229
|
+
inner_acc = forces[member_name]
|
|
230
|
+
if results_key is not None:
|
|
231
|
+
forces[member_name].setdefault(results_key, {})
|
|
232
|
+
inner_acc = forces[member_name][results_key]
|
|
233
|
+
for force_direction, method_type in ACTION_METHODS.items():
|
|
234
|
+
inner_acc[force_direction] = {}
|
|
235
|
+
max_method = getattr(member, f"max_{method_type}")
|
|
236
|
+
min_method = getattr(member, f"min_{method_type}")
|
|
237
|
+
accumulator = inner_acc[force_direction]
|
|
238
|
+
parent_accumulator = inner_acc
|
|
239
|
+
path = force_direction
|
|
240
|
+
|
|
241
|
+
for load_combo_name in load_combinations:
|
|
242
|
+
accumulator[load_combo_name] = {}
|
|
243
|
+
|
|
244
|
+
if method_type not in ("axial", "torque"):
|
|
245
|
+
max_value = float(max_method(force_direction, load_combo_name))
|
|
246
|
+
min_value = float(min_method(force_direction, load_combo_name))
|
|
247
|
+
else:
|
|
248
|
+
max_value = float(max_method(load_combo_name))
|
|
249
|
+
min_value = float(min_method(load_combo_name))
|
|
250
|
+
abs_max_value = max([abs(min_value), abs(max_value)])
|
|
251
|
+
if math.isclose(max_value, 0, abs_tol=1e-8):
|
|
252
|
+
max_value = 0.
|
|
253
|
+
if math.isclose(min_value, 0, abs_tol=1e-8):
|
|
254
|
+
min_value = 0.
|
|
255
|
+
|
|
256
|
+
if min_value == max_value == 0.:
|
|
257
|
+
accumulator.pop(load_combo_name)
|
|
258
|
+
pass
|
|
259
|
+
else:
|
|
260
|
+
accumulator[load_combo_name].update({f"max": round_to_close_integer(max_value)})
|
|
261
|
+
accumulator[load_combo_name].update({f"min": round_to_close_integer(min_value)})
|
|
262
|
+
accumulator[load_combo_name].update({f"absmax": round_to_close_integer(abs_max_value)})
|
|
263
|
+
if not parent_accumulator[path]:
|
|
264
|
+
parent_accumulator.pop(path)
|
|
265
|
+
return forces
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def extract_member_actions_by_location(
|
|
269
|
+
model: FEModel3D,
|
|
270
|
+
force_extraction_locations: Optional[dict[str, list[float]]] = None,
|
|
271
|
+
force_extraction_ratios: Optional[dict[str, list[float]]] = None,
|
|
272
|
+
load_combinations: Optional[list[str]] = None,
|
|
273
|
+
by_span: bool = False,
|
|
274
|
+
results_key: Optional[str] = "action_locations"
|
|
275
|
+
) -> dict[str, dict]:
|
|
276
|
+
"""
|
|
277
|
+
Returns member actions for all members in the 'model' at the
|
|
278
|
+
locations provided. If an action does not have any
|
|
279
|
+
analysis results, then its key is excluded from the result
|
|
280
|
+
tree.
|
|
281
|
+
|
|
282
|
+
'model': A solved Pynite.FEModel3D
|
|
283
|
+
'force_extraction_locations': a dict in the following format:
|
|
284
|
+
|
|
285
|
+
{"member01": [0, 2200, 4300], "member02": [3423, 1500]}
|
|
286
|
+
|
|
287
|
+
Where:
|
|
288
|
+
- "member01" is a member name
|
|
289
|
+
- The values are a list of locations on the member from which to
|
|
290
|
+
extract results from.
|
|
291
|
+
|
|
292
|
+
'force_extraction_ratios': a list or a dict in the following format:
|
|
293
|
+
|
|
294
|
+
{
|
|
295
|
+
"member01": [0.25, 0.5, 0.75],
|
|
296
|
+
"member02": [0.333, 0.666],
|
|
297
|
+
...
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
-or-
|
|
301
|
+
|
|
302
|
+
[0.25, 0.5, 0.75]
|
|
303
|
+
|
|
304
|
+
Where:
|
|
305
|
+
- "member01" is a member name
|
|
306
|
+
- The values are a list of ratios on the member from which to
|
|
307
|
+
extract results from. The location is calculated by
|
|
308
|
+
|
|
309
|
+
ratio * member.L() # length
|
|
310
|
+
|
|
311
|
+
Whether the member is the PhysMember3D (main member)
|
|
312
|
+
or a Member3D (sub member, i.e. an individual span).
|
|
313
|
+
- If a dictionary is supplied, then only those specified members
|
|
314
|
+
will have extracted results. If a list is supplied, then
|
|
315
|
+
the extraction ratio locations will apply to all members.
|
|
316
|
+
|
|
317
|
+
'load_combinations': return results only for these combinations.
|
|
318
|
+
When None, returns all combinations. Default is None.
|
|
319
|
+
'by_span': When True, will assume that the locations provided
|
|
320
|
+
apply to each span for each member, instead of as
|
|
321
|
+
locations specified on the total length of the member.
|
|
322
|
+
'results_key': Optional str value used to nest your results
|
|
323
|
+
more descriptively in the tree. Useful when merging
|
|
324
|
+
multiple results trees. Setting to None will make
|
|
325
|
+
your result tree one level shallower.
|
|
326
|
+
"""
|
|
327
|
+
if load_combinations is None:
|
|
328
|
+
load_combinations = extract_load_combinations(model)
|
|
329
|
+
if force_extraction_locations is None:
|
|
330
|
+
force_extraction_locations = {}
|
|
331
|
+
if force_extraction_ratios is None:
|
|
332
|
+
force_extraction_ratios = {}
|
|
333
|
+
|
|
334
|
+
force_locations = {}
|
|
335
|
+
for member_name, member in model.members.items():
|
|
336
|
+
force_locations.setdefault(member_name, {})
|
|
337
|
+
inner_acc = force_locations[member_name]
|
|
338
|
+
parent_acc = force_locations
|
|
339
|
+
path = member_name
|
|
340
|
+
if results_key is not None:
|
|
341
|
+
force_locations[member_name].setdefault(results_key, {})
|
|
342
|
+
inner_acc = force_locations[member_name][results_key]
|
|
343
|
+
parent_acc = force_locations[member_name]
|
|
344
|
+
path = results_key
|
|
345
|
+
if member_name not in (
|
|
346
|
+
list(force_extraction_locations.keys())
|
|
347
|
+
+ list(force_extraction_ratios.keys())
|
|
348
|
+
):
|
|
349
|
+
continue
|
|
350
|
+
if by_span:
|
|
351
|
+
inner_acc.setdefault("by_span", [])
|
|
352
|
+
for sub_member in member.sub_members.values():
|
|
353
|
+
inner_acc['by_span'].append(
|
|
354
|
+
collect_forces_at_location(
|
|
355
|
+
sub_member,
|
|
356
|
+
member_name,
|
|
357
|
+
force_extraction_locations,
|
|
358
|
+
force_extraction_ratios,
|
|
359
|
+
load_combinations
|
|
360
|
+
)
|
|
361
|
+
)
|
|
362
|
+
else:
|
|
363
|
+
|
|
364
|
+
parent_acc[path] = collect_forces_at_location(
|
|
365
|
+
member,
|
|
366
|
+
member_name,
|
|
367
|
+
force_extraction_locations,
|
|
368
|
+
force_extraction_ratios,
|
|
369
|
+
load_combinations
|
|
370
|
+
)
|
|
371
|
+
return force_locations
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def collect_forces_at_location(
|
|
375
|
+
submember: "Pynite.Member3D",
|
|
376
|
+
member_name: str,
|
|
377
|
+
force_extraction_locations: dict,
|
|
378
|
+
force_extraction_ratios: dict | list,
|
|
379
|
+
load_combinations: list[str]
|
|
380
|
+
) -> dict:
|
|
381
|
+
acc = {}
|
|
382
|
+
for loc in force_extraction_locations.get(member_name,{}):
|
|
383
|
+
acc.update({loc: extract_forces_at_location(submember, loc, load_combinations)})
|
|
384
|
+
|
|
385
|
+
if isinstance(force_extraction_ratios, list):
|
|
386
|
+
ratios_to_extract = force_extraction_ratios
|
|
387
|
+
elif isinstance(force_extraction_ratios, dict):
|
|
388
|
+
ratios_to_extract = force_extraction_ratios.get(member_name)
|
|
389
|
+
else:
|
|
390
|
+
raise ValueError("force_extraction_ratios must be either a dict or list")
|
|
391
|
+
|
|
392
|
+
for ratio in ratios_to_extract:
|
|
393
|
+
length = submember.L()
|
|
394
|
+
loc = length * ratio
|
|
395
|
+
acc.update({ratio: extract_forces_at_location(submember, loc, load_combinations)})
|
|
396
|
+
return acc
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def extract_forces_at_location(member: "Pynite.Member3D", location: float, load_combinations: list[str]):
|
|
400
|
+
loc = location
|
|
401
|
+
acc = {}
|
|
402
|
+
for force_direction, method_type in ACTION_METHODS.items():
|
|
403
|
+
acc.setdefault(force_direction, {})
|
|
404
|
+
force_method = getattr(member, method_type)
|
|
405
|
+
for load_combo_name in load_combinations:
|
|
406
|
+
if method_type not in ("axial", "torque"):
|
|
407
|
+
force_value = round_to_close_integer(force_method(force_direction, loc, load_combo_name))
|
|
408
|
+
else:
|
|
409
|
+
force_value = round_to_close_integer(force_method(loc, load_combo_name))
|
|
410
|
+
acc[force_direction].update({load_combo_name: force_value})
|
|
411
|
+
if all([force_value == 0.0 for force_value in acc[force_direction].values()]):
|
|
412
|
+
acc.pop(force_direction)
|
|
413
|
+
return acc
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def extract_span_envelopes(
|
|
417
|
+
model: FEModel3D,
|
|
418
|
+
load_combinations: Optional[list[str]] = None,
|
|
419
|
+
actions: Optional[list[str]] = None,
|
|
420
|
+
results_key: Optional[str] = "frame_span_envelopes",
|
|
421
|
+
) -> dict:
|
|
422
|
+
"""
|
|
423
|
+
Returns a dict of the following shape which represents the results extract from each span of
|
|
424
|
+
each member in 'model':
|
|
425
|
+
|
|
426
|
+
{
|
|
427
|
+
"member": {
|
|
428
|
+
"result_key": {
|
|
429
|
+
"Action": { # Where Action is one of My, Mz, Fy, Fz, axial, torque, dx, dy
|
|
430
|
+
"LC1": {
|
|
431
|
+
"max": [
|
|
432
|
+
{"value": Yi, "loc_rel": xi, "span_length": li, "loc_abs": Xi, "span": Li},
|
|
433
|
+
...
|
|
434
|
+
],
|
|
435
|
+
"min": [
|
|
436
|
+
{"value": Yi, "loc_rel": xi, "span_length": li, "loc_abs": Xi, "span": Li},
|
|
437
|
+
...
|
|
438
|
+
]
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
'load_combinations': If provided, will only extract these load combinations (extract all if None)
|
|
446
|
+
'actions': If provided, will only extract these actions (extract all if None)
|
|
447
|
+
possible actions: {'Fy', 'Fz', 'My', 'Mz', 'axial', 'torque', 'dy', 'dx'}
|
|
448
|
+
"""
|
|
449
|
+
if actions is None:
|
|
450
|
+
actions = ['Fy', 'Fz', 'My', 'Mz', 'axial', 'torque', 'dy', 'dx']
|
|
451
|
+
max_min = ['max', 'min']
|
|
452
|
+
if load_combinations is None:
|
|
453
|
+
load_combinations = extract_load_combinations(model)
|
|
454
|
+
n_points = 1000
|
|
455
|
+
member_spans = extract_spans(model)
|
|
456
|
+
span_envelopes = {}
|
|
457
|
+
for member_name, sub_members in member_spans.items():
|
|
458
|
+
member_length = model.members[member_name].L()
|
|
459
|
+
span_envelopes.setdefault(member_name, {})
|
|
460
|
+
inner_acc = span_envelopes[member_name]
|
|
461
|
+
node_counts = get_node_counts(model, member_name)
|
|
462
|
+
if results_key is not None:
|
|
463
|
+
span_envelopes[member_name].setdefault(results_key, {})
|
|
464
|
+
inner_acc = span_envelopes[member_name][results_key]
|
|
465
|
+
for force_direction, method_type in ACTION_METHODS.items():
|
|
466
|
+
inner_acc.setdefault(force_direction, {})
|
|
467
|
+
for lc in load_combinations:
|
|
468
|
+
inner_acc[force_direction].setdefault(lc, {})
|
|
469
|
+
method_name = f"{method_type}_array"
|
|
470
|
+
for envelope in max_min:
|
|
471
|
+
envelope_key = f"span_envelope_{envelope}"
|
|
472
|
+
inner_acc[force_direction][lc].setdefault(envelope_key, [])
|
|
473
|
+
length_counter = 0
|
|
474
|
+
for sub_member in sub_members:
|
|
475
|
+
method = getattr(sub_member, method_name)
|
|
476
|
+
if method_type not in ('axial', 'torque'):
|
|
477
|
+
result_arrays = method(force_direction, n_points=n_points, combo_name=lc)
|
|
478
|
+
else:
|
|
479
|
+
result_arrays = method(n_points=n_points, combo_name=lc)
|
|
480
|
+
locator_func = getattr(np, f'arg{envelope}')
|
|
481
|
+
envelope_func = getattr(np, envelope)
|
|
482
|
+
envelope_val = envelope_func(result_arrays[1])
|
|
483
|
+
envelope_idx = locator_func(result_arrays[1])
|
|
484
|
+
x_val_local = result_arrays[0][envelope_idx]
|
|
485
|
+
x_val_global = x_val_local + length_counter
|
|
486
|
+
x_length = result_arrays[0][-1]
|
|
487
|
+
length_counter += x_length
|
|
488
|
+
is_cantilevered = member_is_cantilevered(sub_member, node_counts)
|
|
489
|
+
span_envelope = {
|
|
490
|
+
"value": round_to_close_integer(envelope_val),
|
|
491
|
+
"loc_rel": x_val_local,
|
|
492
|
+
"span_length": sub_member.L(),
|
|
493
|
+
"loc_abs": x_val_global,
|
|
494
|
+
"length": member_length,
|
|
495
|
+
"is_cantilever": is_cantilevered
|
|
496
|
+
}
|
|
497
|
+
inner_acc[force_direction][lc][envelope_key].append(span_envelope)
|
|
498
|
+
return span_envelopes
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def extract_spans(model: FEModel3D) -> dict[str, list["Pynite.Member3D"]]:
|
|
502
|
+
"""
|
|
503
|
+
Extracts the sub-members for all of the members in the 'model'
|
|
504
|
+
"""
|
|
505
|
+
member_spans = {}
|
|
506
|
+
for member_name, member in model.members.items():
|
|
507
|
+
member_spans.setdefault(member_name, [])
|
|
508
|
+
for name, span_member in model.members[member_name].sub_members.items():
|
|
509
|
+
member_spans[member_name].append(span_member)
|
|
510
|
+
return member_spans
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def extract_load_combinations(model: FEModel3D) -> list[str]:
|
|
514
|
+
"""
|
|
515
|
+
Returns a list of the load combination names used in the model
|
|
516
|
+
"""
|
|
517
|
+
return list(model.load_combos.keys())
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def member_is_cantilevered(member: "Pynite.Member3D", node_counts: Counter) -> bool:
|
|
521
|
+
"""
|
|
522
|
+
Returns True if a member is cantilevered. False otherwise.
|
|
523
|
+
"""
|
|
524
|
+
has_two_supports = member_has_two_supports(member)
|
|
525
|
+
if has_two_supports:
|
|
526
|
+
return False
|
|
527
|
+
else:
|
|
528
|
+
i_node = member.i_node
|
|
529
|
+
j_node = member.j_node
|
|
530
|
+
return (
|
|
531
|
+
(node_counts[i_node.name] == 1) and not (node_has_supports(i_node))
|
|
532
|
+
or
|
|
533
|
+
((node_counts[j_node.name] == 1) and not (node_has_supports(j_node)))
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def member_has_two_supports(member: "Pynite.Member3D") -> bool:
|
|
538
|
+
"""
|
|
539
|
+
Returns True if 'member' two supports.
|
|
540
|
+
False if it has less than two supports.
|
|
541
|
+
"""
|
|
542
|
+
return all([
|
|
543
|
+
node_has_supports(member.i_node),
|
|
544
|
+
node_has_supports(member.j_node),
|
|
545
|
+
])
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def member_has_reactions_each_end(member: "Pynite.Member3D") -> bool:
|
|
549
|
+
"""
|
|
550
|
+
Returns True if the 'member' has at least one reaction one both
|
|
551
|
+
ends.
|
|
552
|
+
False, otherwise.
|
|
553
|
+
"""
|
|
554
|
+
reactions_i_tally = []
|
|
555
|
+
reactions_j_tally = []
|
|
556
|
+
for reaction_type in REACTIONS:
|
|
557
|
+
i_node = member.i_node
|
|
558
|
+
j_node = member.j_node
|
|
559
|
+
reactions_i = getattr(i_node, reaction_type)
|
|
560
|
+
reactions_j = getattr(j_node, reaction_type)
|
|
561
|
+
reactions_i_tally.append(any([round_to_close_integer(reaction) for reaction in reactions_i.values()]))
|
|
562
|
+
reactions_j_tally.append(any([round_to_close_integer(reaction) for reaction in reactions_j.values()]))
|
|
563
|
+
return any([reactions_i_tally]) and any([reactions_j_tally])
|
|
564
|
+
|
|
565
|
+
def node_has_supports(node: "Pynite.Node") -> bool:
|
|
566
|
+
"""
|
|
567
|
+
Returns True if 'node' has any supports defined on it.
|
|
568
|
+
False if it is a "free" node.
|
|
569
|
+
"""
|
|
570
|
+
return any([
|
|
571
|
+
node.support_DX,
|
|
572
|
+
node.support_DY,
|
|
573
|
+
node.support_DZ,
|
|
574
|
+
node.support_RX,
|
|
575
|
+
node.support_RY,
|
|
576
|
+
node.support_RZ
|
|
577
|
+
])
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def get_node_counts(model: FEModel3D, member_name: str) -> Counter:
|
|
581
|
+
"""
|
|
582
|
+
Counts the number of times that a node is connected within 'member_name'
|
|
583
|
+
for the purpose of identifying potentially cantilevered sub_members.
|
|
584
|
+
"""
|
|
585
|
+
node_counts = Counter()
|
|
586
|
+
for _, sub_member in model.members[member_name].sub_members.items():
|
|
587
|
+
node_counts.update([sub_member.i_node.name])
|
|
588
|
+
node_counts.update([sub_member.j_node.name])
|
|
589
|
+
return node_counts
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def round_to_close_integer(x: float, eps = 1e-7) -> float | int:
|
|
593
|
+
"""
|
|
594
|
+
Rounds to the nearest int if it is REALLY close
|
|
595
|
+
"""
|
|
596
|
+
if abs(abs(round(x)) - abs(x)) < eps:
|
|
597
|
+
return round(x)
|
|
598
|
+
else:
|
|
599
|
+
return x
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def merge_result_trees(result_trees: list[dict[str, dict]]) -> dict[str, dict]:
|
|
603
|
+
"""
|
|
604
|
+
Merges all of the tress (dictionaries) in 'result_trees'.
|
|
605
|
+
|
|
606
|
+
This is different than a typical dictionary merge (e.g. a | b)
|
|
607
|
+
which will merge dictionaries with different keys but will over-
|
|
608
|
+
write values if two keys are the same.
|
|
609
|
+
|
|
610
|
+
Instead, it crawls each branch of the tree and merges the data
|
|
611
|
+
within each branch, no matter how deep the branches go.
|
|
612
|
+
"""
|
|
613
|
+
acc = {}
|
|
614
|
+
for result_tree in result_trees:
|
|
615
|
+
acc = deepmerge.always_merger.merge(acc, result_tree)
|
|
616
|
+
return acc
|