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.
@@ -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
@@ -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