struct_utils 0.0.1__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.
- struct_utils/__init__.py +2 -0
- struct_utils/general_utils/__init__.py +2 -0
- struct_utils/general_utils/logprint.py +132 -0
- struct_utils/software_utils/__init__.py +3 -0
- struct_utils/software_utils/folder_structure_tree.py +24 -0
- struct_utils/structural_utils/NASA_TM_108378_fitting_factor.py +228 -0
- struct_utils/structural_utils/__init__.py +17 -0
- struct_utils/structural_utils/bolt_pattern_elastic_method.py +755 -0
- struct_utils/structural_utils/bolt_pattern_elastic_method_README.md +60 -0
- struct_utils/structural_utils/margin_table.py +306 -0
- struct_utils/structural_utils/shared_helpers.py +46 -0
- struct_utils-0.0.1.dist-info/METADATA +70 -0
- struct_utils-0.0.1.dist-info/RECORD +15 -0
- struct_utils-0.0.1.dist-info/WHEEL +4 -0
- struct_utils-0.0.1.dist-info/licenses/LICENSE.txt +21 -0
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bolt Pattern Force Distribution
|
|
3
|
+
================================
|
|
4
|
+
Implements the methodology from:
|
|
5
|
+
https://mechanicalc.com/reference/bolt-pattern-force-distribution
|
|
6
|
+
|
|
7
|
+
Given a bolt pattern (positions + areas) and applied loads at arbitrary
|
|
8
|
+
locations, this module computes the axial and shear force on each bolt.
|
|
9
|
+
|
|
10
|
+
Coordinate system
|
|
11
|
+
-----------------
|
|
12
|
+
X, Y - in-plane axes (the plane of the bolt pattern)
|
|
13
|
+
Z - out-of-plane axis (bolt axis direction)
|
|
14
|
+
|
|
15
|
+
Applied load / moment sign convention follows the right-hand rule.
|
|
16
|
+
|
|
17
|
+
Usage example at the bottom of this file.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import math
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Sequence
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Data structures
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
_bolt_counter = 0
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Bolt:
|
|
34
|
+
"""A single bolt in the pattern.
|
|
35
|
+
|
|
36
|
+
Parameters
|
|
37
|
+
----------
|
|
38
|
+
x, y : float
|
|
39
|
+
In-plane position of the bolt.
|
|
40
|
+
area : float
|
|
41
|
+
Tensile stress area of the bolt (default 1.0 for equal-size bolts).
|
|
42
|
+
label : str
|
|
43
|
+
Auto-assigned as B1, B2, ... if not provided.
|
|
44
|
+
"""
|
|
45
|
+
x: float
|
|
46
|
+
y: float
|
|
47
|
+
area: float = 1.0
|
|
48
|
+
label: str = ""
|
|
49
|
+
|
|
50
|
+
def __post_init__(self):
|
|
51
|
+
global _bolt_counter
|
|
52
|
+
if not self.label:
|
|
53
|
+
_bolt_counter += 1
|
|
54
|
+
self.label = f"B{_bolt_counter}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class AppliedLoad:
|
|
59
|
+
"""A force/moment applied at a specific point.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
Fx, Fy, Fz : float
|
|
64
|
+
Force components (Fz is axial / out-of-plane).
|
|
65
|
+
Mx, My, Mz : float
|
|
66
|
+
Moment components about each axis (right-hand rule).
|
|
67
|
+
x, y, z : float
|
|
68
|
+
Location at which the load is applied.
|
|
69
|
+
The moments are transferred to the pattern centroid automatically.
|
|
70
|
+
"""
|
|
71
|
+
Fx: float = 0.0
|
|
72
|
+
Fy: float = 0.0
|
|
73
|
+
Fz: float = 0.0
|
|
74
|
+
Mx: float = 0.0
|
|
75
|
+
My: float = 0.0
|
|
76
|
+
Mz: float = 0.0
|
|
77
|
+
x: float = 0.0
|
|
78
|
+
y: float = 0.0
|
|
79
|
+
z: float = 0.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class BoltResult:
|
|
84
|
+
"""Forces resolved onto a single bolt."""
|
|
85
|
+
bolt: Bolt
|
|
86
|
+
# Axial (Z-direction) components
|
|
87
|
+
Fz_direct: float = 0.0 # due to direct Fz at centroid
|
|
88
|
+
Fz_Mcx: float = 0.0 # due to centroidal moment about X
|
|
89
|
+
Fz_Mcy: float = 0.0 # due to centroidal moment about Y
|
|
90
|
+
# Shear components
|
|
91
|
+
Fxy_direct_x: float = 0.0 # direct Fx distributed by area
|
|
92
|
+
Fxy_direct_y: float = 0.0 # direct Fy distributed by area
|
|
93
|
+
Fxy_Mcz_x: float = 0.0 # torsional moment – X component
|
|
94
|
+
Fxy_Mcz_y: float = 0.0 # torsional moment – Y component
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def Fz_total(self) -> float:
|
|
98
|
+
"""Total axial force on this bolt."""
|
|
99
|
+
return self.Fz_direct + self.Fz_Mcx + self.Fz_Mcy
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def Fx_total(self) -> float:
|
|
103
|
+
return self.Fxy_direct_x + self.Fxy_Mcz_x
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def Fy_total(self) -> float:
|
|
107
|
+
return self.Fxy_direct_y + self.Fxy_Mcz_y
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def F_shear(self) -> float:
|
|
111
|
+
"""Resultant shear magnitude."""
|
|
112
|
+
return math.hypot(self.Fx_total, self.Fy_total)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def F_total(self) -> float:
|
|
116
|
+
"""Total resultant force magnitude."""
|
|
117
|
+
return math.sqrt(self.Fx_total**2 + self.Fy_total**2 + self.Fz_total**2)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
# Core computation
|
|
122
|
+
# ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@dataclass
|
|
125
|
+
class BoltPatternAnalysis:
|
|
126
|
+
"""Analyse force distribution over a bolt pattern.
|
|
127
|
+
|
|
128
|
+
Parameters
|
|
129
|
+
----------
|
|
130
|
+
bolts : list[Bolt]
|
|
131
|
+
The bolt pattern.
|
|
132
|
+
loads : list[AppliedLoad]
|
|
133
|
+
All applied loads (can be at arbitrary locations).
|
|
134
|
+
"""
|
|
135
|
+
bolts: list[Bolt]
|
|
136
|
+
loads: list[AppliedLoad] = field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
# ---- Pattern properties (computed lazily) ----
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def total_area(self) -> float:
|
|
142
|
+
"""Sum of bolt areas."""
|
|
143
|
+
return sum(b.area for b in self.bolts)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def centroid(self) -> tuple[float, float]:
|
|
147
|
+
"""(xc, yc) – area-weighted centroid of the bolt pattern."""
|
|
148
|
+
A = self.total_area
|
|
149
|
+
xc = sum(b.area * b.x for b in self.bolts) / A
|
|
150
|
+
yc = sum(b.area * b.y for b in self.bolts) / A
|
|
151
|
+
return xc, yc
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def Ic_x(self) -> float:
|
|
155
|
+
"""Second moment of area about the centroidal X-axis (bending about X)."""
|
|
156
|
+
xc, yc = self.centroid
|
|
157
|
+
return sum(b.area * (b.y - yc)**2 for b in self.bolts)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def Ic_y(self) -> float:
|
|
161
|
+
"""Second moment of area about the centroidal Y-axis (bending about Y)."""
|
|
162
|
+
xc, yc = self.centroid
|
|
163
|
+
return sum(b.area * (b.x - xc)**2 for b in self.bolts)
|
|
164
|
+
|
|
165
|
+
@property
|
|
166
|
+
def Ic_p(self) -> float:
|
|
167
|
+
"""Polar moment of area about the centroid (torsion about Z)."""
|
|
168
|
+
xc, yc = self.centroid
|
|
169
|
+
return sum(b.area * ((b.x - xc)**2 + (b.y - yc)**2) for b in self.bolts)
|
|
170
|
+
|
|
171
|
+
# ---- Translate all loads to the centroid ----
|
|
172
|
+
|
|
173
|
+
def centroidal_loads(self) -> tuple[float, float, float, float, float, float]:
|
|
174
|
+
"""Return (Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z) at the pattern centroid.
|
|
175
|
+
|
|
176
|
+
Each applied load is moved to the centroid using the cross-product
|
|
177
|
+
transfer rule: M_new = M_applied + R x F
|
|
178
|
+
where R is the vector from the centroid to the load application point.
|
|
179
|
+
"""
|
|
180
|
+
xc, yc = self.centroid
|
|
181
|
+
|
|
182
|
+
Fc_x = Fc_y = Fc_z = 0.0
|
|
183
|
+
Mc_x = Mc_y = Mc_z = 0.0
|
|
184
|
+
|
|
185
|
+
for load in self.loads:
|
|
186
|
+
# Direct force sums
|
|
187
|
+
Fc_x += load.Fx
|
|
188
|
+
Fc_y += load.Fy
|
|
189
|
+
Fc_z += load.Fz
|
|
190
|
+
|
|
191
|
+
# Moment transfer: R = (load.x - xc, load.y - yc, load.z - 0)
|
|
192
|
+
rx = load.x - xc
|
|
193
|
+
ry = load.y - yc
|
|
194
|
+
rz = load.z # centroid is at z=0 by convention
|
|
195
|
+
|
|
196
|
+
# Cross product R × F (right-hand rule)
|
|
197
|
+
cross_x = ry * load.Fz - rz * load.Fy
|
|
198
|
+
cross_y = rz * load.Fx - rx * load.Fz
|
|
199
|
+
cross_z = rx * load.Fy - ry * load.Fx
|
|
200
|
+
|
|
201
|
+
Mc_x += load.Mx + cross_x
|
|
202
|
+
Mc_y += load.My + cross_y
|
|
203
|
+
Mc_z += load.Mz + cross_z
|
|
204
|
+
|
|
205
|
+
return Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z
|
|
206
|
+
|
|
207
|
+
# ---- Distribute to individual bolts ----
|
|
208
|
+
|
|
209
|
+
def solve(self) -> list[BoltResult]:
|
|
210
|
+
"""Compute and return the force on each bolt."""
|
|
211
|
+
xc, yc = self.centroid
|
|
212
|
+
A = self.total_area
|
|
213
|
+
Ic_x = self.Ic_x
|
|
214
|
+
Ic_y = self.Ic_y
|
|
215
|
+
Ic_p = self.Ic_p
|
|
216
|
+
|
|
217
|
+
Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = self.centroidal_loads()
|
|
218
|
+
|
|
219
|
+
results = []
|
|
220
|
+
for b in self.bolts:
|
|
221
|
+
r = BoltResult(bolt=b)
|
|
222
|
+
|
|
223
|
+
# Distance of bolt from centroid
|
|
224
|
+
dx = b.x - xc # rc_y component (distance in X from centroid)
|
|
225
|
+
dy = b.y - yc # rc_x component (distance in Y from centroid)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
# Axial forces (Z direction)
|
|
229
|
+
# ------------------------------------------------------------------
|
|
230
|
+
# 1. Direct Fz distributed proportional to bolt area
|
|
231
|
+
r.Fz_direct = (b.area / A) * Fc_z
|
|
232
|
+
|
|
233
|
+
# 2. Moment Mc_x about X-axis → tensile/compressive along Z
|
|
234
|
+
# F = (Mc_x * rc_x,i / Ic_x) * A_i
|
|
235
|
+
# rc_x,i = dy (distance from centroid in Y direction)
|
|
236
|
+
if abs(Ic_x) > 0:
|
|
237
|
+
r.Fz_Mcx = (Mc_x * dy / Ic_x) * b.area
|
|
238
|
+
else:
|
|
239
|
+
r.Fz_Mcx = 0.0
|
|
240
|
+
|
|
241
|
+
# 3. Moment Mc_y about Y-axis → tensile/compressive along Z
|
|
242
|
+
# rc_y,i = dx (distance from centroid in X direction)
|
|
243
|
+
# Note: sign convention – positive Mc_y acts in -Z for bolts at +X
|
|
244
|
+
if abs(Ic_y) > 0:
|
|
245
|
+
r.Fz_Mcy = -(Mc_y * dx / Ic_y) * b.area
|
|
246
|
+
else:
|
|
247
|
+
r.Fz_Mcy = 0.0
|
|
248
|
+
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
# Shear forces (X-Y plane)
|
|
251
|
+
# ------------------------------------------------------------------
|
|
252
|
+
# 1. Direct shear distributed proportional to bolt area
|
|
253
|
+
r.Fxy_direct_x = (b.area / A) * Fc_x
|
|
254
|
+
r.Fxy_direct_y = (b.area / A) * Fc_y
|
|
255
|
+
|
|
256
|
+
# 2. Torsional moment Mc_z – shear perpendicular to radius vector
|
|
257
|
+
# F_i = (Mc_z * r_i / Ic_p) * A_i (magnitude)
|
|
258
|
+
# Direction: perpendicular to (dx, dy), i.e. (-dy, dx) normalized
|
|
259
|
+
if abs(Ic_p) > 0:
|
|
260
|
+
scale = (Mc_z / Ic_p) * b.area
|
|
261
|
+
r.Fxy_Mcz_x = -scale * dy
|
|
262
|
+
r.Fxy_Mcz_y = scale * dx
|
|
263
|
+
else:
|
|
264
|
+
r.Fxy_Mcz_x = 0.0
|
|
265
|
+
r.Fxy_Mcz_y = 0.0
|
|
266
|
+
|
|
267
|
+
results.append(r)
|
|
268
|
+
|
|
269
|
+
return results
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------------------
|
|
273
|
+
# Convenience function
|
|
274
|
+
# ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def bolt_pattern_force_distribution(
|
|
277
|
+
bolt_positions: Sequence[tuple[float, float]],
|
|
278
|
+
applied_loads: Sequence[dict],
|
|
279
|
+
bolt_areas: Sequence[float] | None = None,
|
|
280
|
+
bolt_labels: Sequence[str] | None = None,
|
|
281
|
+
) -> list[BoltResult]:
|
|
282
|
+
"""High-level helper function.
|
|
283
|
+
|
|
284
|
+
Parameters
|
|
285
|
+
----------
|
|
286
|
+
bolt_positions : list of (x, y)
|
|
287
|
+
In-plane coordinates of each bolt.
|
|
288
|
+
applied_loads : list of dicts
|
|
289
|
+
Each dict may contain keys: Fx, Fy, Fz, Mx, My, Mz, x, y, z.
|
|
290
|
+
Missing keys default to 0.
|
|
291
|
+
bolt_areas : list of float, optional
|
|
292
|
+
Tensile stress area of each bolt. Defaults to 1.0 for all bolts
|
|
293
|
+
(equal-size bolts, which simplifies area-weighted sums).
|
|
294
|
+
bolt_labels : list of str, optional
|
|
295
|
+
Human-readable names for the bolts.
|
|
296
|
+
|
|
297
|
+
Returns
|
|
298
|
+
-------
|
|
299
|
+
list[BoltResult]
|
|
300
|
+
One entry per bolt with decomposed and total forces.
|
|
301
|
+
"""
|
|
302
|
+
n = len(bolt_positions)
|
|
303
|
+
if bolt_areas is None:
|
|
304
|
+
bolt_areas = [1.0] * n
|
|
305
|
+
if bolt_labels is None:
|
|
306
|
+
bolt_labels = [f"Bolt {i+1}" for i in range(n)]
|
|
307
|
+
|
|
308
|
+
bolts = [
|
|
309
|
+
Bolt(x=pos[0], y=pos[1], area=a, label=lbl)
|
|
310
|
+
for pos, a, lbl in zip(bolt_positions, bolt_areas, bolt_labels)
|
|
311
|
+
]
|
|
312
|
+
|
|
313
|
+
loads = [
|
|
314
|
+
AppliedLoad(
|
|
315
|
+
Fx=ld.get("Fx", 0.0),
|
|
316
|
+
Fy=ld.get("Fy", 0.0),
|
|
317
|
+
Fz=ld.get("Fz", 0.0),
|
|
318
|
+
Mx=ld.get("Mx", 0.0),
|
|
319
|
+
My=ld.get("My", 0.0),
|
|
320
|
+
Mz=ld.get("Mz", 0.0),
|
|
321
|
+
x=ld.get("x", 0.0),
|
|
322
|
+
y=ld.get("y", 0.0),
|
|
323
|
+
z=ld.get("z", 0.0),
|
|
324
|
+
)
|
|
325
|
+
for ld in applied_loads
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
analysis = BoltPatternAnalysis(bolts=bolts, loads=loads)
|
|
329
|
+
return analysis.solve()
|
|
330
|
+
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
# Interactive 3-D HTML visualisation (Plotly)
|
|
333
|
+
# ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def plot_bolt_pattern_3d(
|
|
338
|
+
analysis: BoltPatternAnalysis,
|
|
339
|
+
results: list[BoltResult],
|
|
340
|
+
title: str = "Bolt Pattern Force Distribution",
|
|
341
|
+
show: bool = False,
|
|
342
|
+
save_dir: str | None = None, # folder to save into; defaults to cwd
|
|
343
|
+
) -> "plotly.graph_objects.Figure":
|
|
344
|
+
"""Render an interactive 3-D visualisation and save as a self-contained HTML file.
|
|
345
|
+
|
|
346
|
+
The HTML filename is derived automatically from *title* by lowercasing,
|
|
347
|
+
replacing spaces and special characters with underscores, and appending
|
|
348
|
+
".html". For example "XM Bracket load case 3" becomes
|
|
349
|
+
"xm_bracket_load_case_3.html".
|
|
350
|
+
|
|
351
|
+
Arrow colour convention
|
|
352
|
+
-----------------------
|
|
353
|
+
Blue – shear resultant (in-plane)
|
|
354
|
+
Green – axial tension (+Z)
|
|
355
|
+
Red – axial compression (−Z)
|
|
356
|
+
Orange – resultant force vector
|
|
357
|
+
Purple – applied load arrows
|
|
358
|
+
|
|
359
|
+
Parameters
|
|
360
|
+
----------
|
|
361
|
+
analysis : BoltPatternAnalysis
|
|
362
|
+
results : list[BoltResult] from analysis.solve()
|
|
363
|
+
title : str figure title; also used as the filename stem
|
|
364
|
+
show : bool open the figure in the default browser
|
|
365
|
+
save_dir : str | None folder to write the HTML file into;
|
|
366
|
+
defaults to the current working directory
|
|
367
|
+
|
|
368
|
+
Returns
|
|
369
|
+
-------
|
|
370
|
+
plotly.graph_objects.Figure
|
|
371
|
+
"""
|
|
372
|
+
import re, os
|
|
373
|
+
import plotly.graph_objects as go
|
|
374
|
+
|
|
375
|
+
slug = re.sub(r"[^\w\s]", "", title)
|
|
376
|
+
slug = re.sub(r"\s+", "_", slug.strip()).lower() or "bolt_pattern"
|
|
377
|
+
folder = save_dir if save_dir is not None else "."
|
|
378
|
+
os.makedirs(folder, exist_ok=True)
|
|
379
|
+
save_path = os.path.join(folder, slug + ".html")
|
|
380
|
+
|
|
381
|
+
xc, yc = analysis.centroid
|
|
382
|
+
Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = analysis.centroidal_loads()
|
|
383
|
+
|
|
384
|
+
# ── Scale arrows to ~40 % of pattern span ────────────────────────────────
|
|
385
|
+
bolt_xs = [b.bolt.x for b in results]
|
|
386
|
+
bolt_ys = [b.bolt.y for b in results]
|
|
387
|
+
span = max(max(bolt_xs) - min(bolt_xs), max(bolt_ys) - min(bolt_ys), 1e-6)
|
|
388
|
+
max_mag = max((r.F_total for r in results), default=1.0) or 1.0
|
|
389
|
+
arrow_scale = span * 0.40 / max_mag
|
|
390
|
+
cyl_h = span * 0.06 # bolt cylinder half-height
|
|
391
|
+
|
|
392
|
+
# ── Colour scale for bolt markers (total force magnitude) ─────────────────
|
|
393
|
+
ft_vals = [r.F_total for r in results]
|
|
394
|
+
|
|
395
|
+
traces = []
|
|
396
|
+
|
|
397
|
+
# ── Bolt plate (semi-transparent rectangle at Z=0) ────────────────────────
|
|
398
|
+
pad = span * 0.18
|
|
399
|
+
px = [min(bolt_xs)-pad, max(bolt_xs)+pad, max(bolt_xs)+pad, min(bolt_xs)-pad, min(bolt_xs)-pad]
|
|
400
|
+
py = [min(bolt_ys)-pad, min(bolt_ys)-pad, max(bolt_ys)+pad, max(bolt_ys)+pad, min(bolt_ys)-pad]
|
|
401
|
+
traces.append(go.Scatter3d(
|
|
402
|
+
x=px, y=py, z=[0]*5,
|
|
403
|
+
mode="lines",
|
|
404
|
+
line=dict(color="#58a6ff", width=2),
|
|
405
|
+
name="Plate outline",
|
|
406
|
+
showlegend=False,
|
|
407
|
+
))
|
|
408
|
+
# Filled surface via Mesh3d (two triangles)
|
|
409
|
+
traces.append(go.Mesh3d(
|
|
410
|
+
x=[min(bolt_xs)-pad, max(bolt_xs)+pad, max(bolt_xs)+pad, min(bolt_xs)-pad],
|
|
411
|
+
y=[min(bolt_ys)-pad, min(bolt_ys)-pad, max(bolt_ys)+pad, max(bolt_ys)+pad],
|
|
412
|
+
z=[0, 0, 0, 0],
|
|
413
|
+
i=[0, 0], j=[1, 2], k=[2, 3],
|
|
414
|
+
color="#58a6ff", opacity=0.10,
|
|
415
|
+
name="Plate", showlegend=False, hoverinfo="skip",
|
|
416
|
+
))
|
|
417
|
+
|
|
418
|
+
# ── Bolt cylinders (markers) ───────────────────────────────────────────────
|
|
419
|
+
traces.append(go.Scatter3d(
|
|
420
|
+
x=[r.bolt.x for r in results],
|
|
421
|
+
y=[r.bolt.y for r in results],
|
|
422
|
+
z=[cyl_h for r in results],
|
|
423
|
+
mode="markers+text",
|
|
424
|
+
marker=dict(
|
|
425
|
+
size=10,
|
|
426
|
+
color=ft_vals,
|
|
427
|
+
colorscale="Plasma",
|
|
428
|
+
cmin=0, cmax=max_mag,
|
|
429
|
+
colorbar=dict(
|
|
430
|
+
title=dict(text="|F_total|", font=dict(color="#e6edf3")),
|
|
431
|
+
thickness=14, len=0.55, x=1.01,
|
|
432
|
+
tickfont=dict(color="#e6edf3"),
|
|
433
|
+
),
|
|
434
|
+
line=dict(color="#30363d", width=1),
|
|
435
|
+
),
|
|
436
|
+
text=[r.bolt.label for r in results],
|
|
437
|
+
textposition="top center",
|
|
438
|
+
textfont=dict(color="#e6edf3", size=11),
|
|
439
|
+
name="Bolts",
|
|
440
|
+
customdata=[[r.Fz_total, r.Fx_total, r.Fy_total, r.F_shear, r.F_total]
|
|
441
|
+
for r in results],
|
|
442
|
+
hovertemplate=(
|
|
443
|
+
"<b>%{text}</b><br>"
|
|
444
|
+
"Fz = %{customdata[0]:.2f}<br>"
|
|
445
|
+
"Fx = %{customdata[1]:.2f}<br>"
|
|
446
|
+
"Fy = %{customdata[2]:.2f}<br>"
|
|
447
|
+
"|F_shear| = %{customdata[3]:.2f}<br>"
|
|
448
|
+
"|F_total| = %{customdata[4]:.2f}<extra></extra>"
|
|
449
|
+
),
|
|
450
|
+
))
|
|
451
|
+
|
|
452
|
+
# ── Helper: add a cone (arrow) trace ──────────────────────────────────────
|
|
453
|
+
def _arrow(ox, oy, oz, ux, uy, uz, color, name, legendgroup, showlegend=True):
|
|
454
|
+
mag = math.sqrt(ux**2 + uy**2 + uz**2)
|
|
455
|
+
if mag < 1e-9:
|
|
456
|
+
return
|
|
457
|
+
# Stem
|
|
458
|
+
traces.append(go.Scatter3d(
|
|
459
|
+
x=[ox, ox+ux], y=[oy, oy+uy], z=[oz, oz+uz],
|
|
460
|
+
mode="lines",
|
|
461
|
+
line=dict(color=color, width=4),
|
|
462
|
+
name=name, legendgroup=legendgroup,
|
|
463
|
+
showlegend=showlegend,
|
|
464
|
+
hoverinfo="skip",
|
|
465
|
+
))
|
|
466
|
+
# Arrowhead cone
|
|
467
|
+
traces.append(go.Cone(
|
|
468
|
+
x=[ox+ux], y=[oy+uy], z=[oz+uz],
|
|
469
|
+
u=[ux*0.25], v=[uy*0.25], w=[uz*0.25],
|
|
470
|
+
colorscale=[[0, color], [1, color]],
|
|
471
|
+
showscale=False,
|
|
472
|
+
sizemode="absolute", sizeref=span*0.04,
|
|
473
|
+
name=name, legendgroup=legendgroup,
|
|
474
|
+
showlegend=False,
|
|
475
|
+
hoverinfo="skip",
|
|
476
|
+
))
|
|
477
|
+
|
|
478
|
+
# ── Per-bolt force arrows ─────────────────────────────────────────────────
|
|
479
|
+
for i, r in enumerate(results):
|
|
480
|
+
bx, by = r.bolt.x, r.bolt.y
|
|
481
|
+
oz = cyl_h
|
|
482
|
+
first = (i == 0) # only first bolt contributes legend entry per group
|
|
483
|
+
|
|
484
|
+
# Shear
|
|
485
|
+
fs = r.F_shear
|
|
486
|
+
if fs > 1e-9:
|
|
487
|
+
_arrow(bx, by, oz,
|
|
488
|
+
r.Fx_total * arrow_scale, r.Fy_total * arrow_scale, 0,
|
|
489
|
+
"#388bfd", "Shear", "shear", showlegend=first)
|
|
490
|
+
|
|
491
|
+
# Axial
|
|
492
|
+
fz = r.Fz_total
|
|
493
|
+
if abs(fz) > 1e-9:
|
|
494
|
+
color = "#3fb950" if fz > 0 else "#f85149"
|
|
495
|
+
name = "Axial +Z (tension)" if fz > 0 else "Axial −Z (compression)"
|
|
496
|
+
group = "axial_pos" if fz > 0 else "axial_neg"
|
|
497
|
+
_arrow(bx, by, oz, 0, 0, fz * arrow_scale,
|
|
498
|
+
color, name, group, showlegend=first)
|
|
499
|
+
|
|
500
|
+
# Resultant
|
|
501
|
+
ft = r.F_total
|
|
502
|
+
if ft > 1e-9:
|
|
503
|
+
_arrow(bx, by, oz,
|
|
504
|
+
r.Fx_total * arrow_scale,
|
|
505
|
+
r.Fy_total * arrow_scale,
|
|
506
|
+
r.Fz_total * arrow_scale,
|
|
507
|
+
"#ffa657", "Resultant", "resultant", showlegend=first)
|
|
508
|
+
|
|
509
|
+
# ── Applied load arrows (from their 3-D application point) ───────────────
|
|
510
|
+
app_mags = [math.sqrt(l.Fx**2 + l.Fy**2 + l.Fz**2) for l in analysis.loads]
|
|
511
|
+
app_max = max(app_mags, default=1.0) or 1.0
|
|
512
|
+
app_scale = span * 0.35 / app_max
|
|
513
|
+
for i, (ld, mag) in enumerate(zip(analysis.loads, app_mags)):
|
|
514
|
+
if mag < 1e-9:
|
|
515
|
+
continue
|
|
516
|
+
_arrow(ld.x, ld.y, ld.z,
|
|
517
|
+
ld.Fx * app_scale, ld.Fy * app_scale, ld.Fz * app_scale,
|
|
518
|
+
"#bc8cff", "Applied load", "applied", showlegend=(i == 0))
|
|
519
|
+
|
|
520
|
+
# ── Applied moment arcs ───────────────────────────────────────────────────
|
|
521
|
+
# Each moment vector M = (Mx, My, Mz) is drawn as a 270° circular arc in
|
|
522
|
+
# the plane whose normal is M̂, centred at the load application point.
|
|
523
|
+
# A cone arrowhead at the arc tip shows the right-hand-rule direction.
|
|
524
|
+
def _moment_arc(ox, oy, oz, mx, my, mz, color, name, legendgroup, showlegend=True):
|
|
525
|
+
mag = math.sqrt(mx**2 + my**2 + mz**2)
|
|
526
|
+
if mag < 1e-9:
|
|
527
|
+
return
|
|
528
|
+
|
|
529
|
+
# Unit moment axis (right-hand rule: curl fingers → rotation direction)
|
|
530
|
+
ax_hat = (mx / mag, my / mag, mz / mag)
|
|
531
|
+
|
|
532
|
+
# Build two orthogonal vectors in the plane perpendicular to ax_hat
|
|
533
|
+
# Use a helper vector that is not parallel to ax_hat
|
|
534
|
+
helper = (0.0, 0.0, 1.0) if abs(ax_hat[2]) < 0.9 else (1.0, 0.0, 0.0)
|
|
535
|
+
# u1 = helper × ax_hat (in-plane basis vector 1)
|
|
536
|
+
u1 = (
|
|
537
|
+
helper[1]*ax_hat[2] - helper[2]*ax_hat[1],
|
|
538
|
+
helper[2]*ax_hat[0] - helper[0]*ax_hat[2],
|
|
539
|
+
helper[0]*ax_hat[1] - helper[1]*ax_hat[0],
|
|
540
|
+
)
|
|
541
|
+
n1 = math.sqrt(u1[0]**2 + u1[1]**2 + u1[2]**2)
|
|
542
|
+
u1 = tuple(c / n1 for c in u1)
|
|
543
|
+
# u2 = ax_hat × u1 (in-plane basis vector 2)
|
|
544
|
+
u2 = (
|
|
545
|
+
ax_hat[1]*u1[2] - ax_hat[2]*u1[1],
|
|
546
|
+
ax_hat[2]*u1[0] - ax_hat[0]*u1[2],
|
|
547
|
+
ax_hat[0]*u1[1] - ax_hat[1]*u1[0],
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Arc radius proportional to pattern span; 270° sweep (leave gap to read direction)
|
|
551
|
+
r_arc = span * 0.22
|
|
552
|
+
n_pts = 48
|
|
553
|
+
sweep = 1.5 * math.pi # 270°
|
|
554
|
+
angles = [sweep * k / (n_pts - 1) for k in range(n_pts)]
|
|
555
|
+
|
|
556
|
+
arc_x = [ox + r_arc * (math.cos(a)*u1[0] + math.sin(a)*u2[0]) for a in angles]
|
|
557
|
+
arc_y = [oy + r_arc * (math.cos(a)*u1[1] + math.sin(a)*u2[1]) for a in angles]
|
|
558
|
+
arc_z = [oz + r_arc * (math.cos(a)*u1[2] + math.sin(a)*u2[2]) for a in angles]
|
|
559
|
+
|
|
560
|
+
traces.append(go.Scatter3d(
|
|
561
|
+
x=arc_x, y=arc_y, z=arc_z,
|
|
562
|
+
mode="lines",
|
|
563
|
+
line=dict(color=color, width=4),
|
|
564
|
+
name=name, legendgroup=legendgroup,
|
|
565
|
+
showlegend=showlegend,
|
|
566
|
+
hovertemplate=f"M=({mx:.2f}, {my:.2f}, {mz:.2f})<extra>{name}</extra>",
|
|
567
|
+
))
|
|
568
|
+
|
|
569
|
+
# Arrowhead cone tangent to the arc at its tip
|
|
570
|
+
# Tangent direction = d/da [cos(a)*u1 + sin(a)*u2] at a=sweep
|
|
571
|
+
# = -sin(sweep)*u1 + cos(sweep)*u2
|
|
572
|
+
tx = -math.sin(sweep)*u1[0] + math.cos(sweep)*u2[0]
|
|
573
|
+
ty = -math.sin(sweep)*u1[1] + math.cos(sweep)*u2[1]
|
|
574
|
+
tz = -math.sin(sweep)*u1[2] + math.cos(sweep)*u2[2]
|
|
575
|
+
cone_size = span * 0.06
|
|
576
|
+
traces.append(go.Cone(
|
|
577
|
+
x=[arc_x[-1]], y=[arc_y[-1]], z=[arc_z[-1]],
|
|
578
|
+
u=[tx * cone_size], v=[ty * cone_size], w=[tz * cone_size],
|
|
579
|
+
colorscale=[[0, color], [1, color]],
|
|
580
|
+
showscale=False,
|
|
581
|
+
sizemode="absolute", sizeref=cone_size,
|
|
582
|
+
name=name, legendgroup=legendgroup,
|
|
583
|
+
showlegend=False,
|
|
584
|
+
hoverinfo="skip",
|
|
585
|
+
))
|
|
586
|
+
|
|
587
|
+
# Centre dot at application point
|
|
588
|
+
traces.append(go.Scatter3d(
|
|
589
|
+
x=[ox], y=[oy], z=[oz],
|
|
590
|
+
mode="markers",
|
|
591
|
+
marker=dict(symbol="circle", size=5, color=color),
|
|
592
|
+
name=name, legendgroup=legendgroup,
|
|
593
|
+
showlegend=False, hoverinfo="skip",
|
|
594
|
+
))
|
|
595
|
+
|
|
596
|
+
mom_mags = [math.sqrt(l.Mx**2 + l.My**2 + l.Mz**2) for l in analysis.loads]
|
|
597
|
+
first_moment = True
|
|
598
|
+
for ld, mmag in zip(analysis.loads, mom_mags):
|
|
599
|
+
if mmag < 1e-9:
|
|
600
|
+
continue
|
|
601
|
+
_moment_arc(ld.x, ld.y, ld.z,
|
|
602
|
+
ld.Mx, ld.My, ld.Mz,
|
|
603
|
+
"#ff9f43", "Applied moment", "applied_m",
|
|
604
|
+
showlegend=first_moment)
|
|
605
|
+
first_moment = False
|
|
606
|
+
|
|
607
|
+
# ── Centroid marker ───────────────────────────────────────────────────────
|
|
608
|
+
traces.append(go.Scatter3d(
|
|
609
|
+
x=[xc], y=[yc], z=[0],
|
|
610
|
+
mode="markers+text",
|
|
611
|
+
marker=dict(symbol="cross", size=8, color="#d2a8ff",
|
|
612
|
+
line=dict(color="#d2a8ff", width=2)),
|
|
613
|
+
text=["centroid"], textposition="top center",
|
|
614
|
+
textfont=dict(color="#d2a8ff", size=9),
|
|
615
|
+
name="Centroid", showlegend=True,
|
|
616
|
+
hovertemplate=f"Centroid ({xc:.3f}, {yc:.3f})<extra></extra>",
|
|
617
|
+
))
|
|
618
|
+
|
|
619
|
+
# ── Layout ────────────────────────────────────────────────────────────────
|
|
620
|
+
# Build subtitle with centroidal loads
|
|
621
|
+
subtitle = (f"Centroid ({xc:.3f}, {yc:.3f}) | "
|
|
622
|
+
f"Fc=({Fc_x:.1f}, {Fc_y:.1f}, {Fc_z:.1f}) | "
|
|
623
|
+
f"Mc=({Mc_x:.1f}, {Mc_y:.1f}, {Mc_z:.1f})")
|
|
624
|
+
|
|
625
|
+
fig = go.Figure(data=traces)
|
|
626
|
+
fig.update_layout(
|
|
627
|
+
title=dict(
|
|
628
|
+
text=f"<b>{title}</b><br><sup>{subtitle}</sup>",
|
|
629
|
+
font=dict(color="#e6edf3", size=14),
|
|
630
|
+
x=0.5, xanchor="center",
|
|
631
|
+
),
|
|
632
|
+
scene=dict(
|
|
633
|
+
xaxis=dict(title="X", backgroundcolor="#161b22",
|
|
634
|
+
gridcolor="#21262d", showbackground=True,
|
|
635
|
+
tickfont=dict(color="#8b949e")),
|
|
636
|
+
yaxis=dict(title="Y", backgroundcolor="#161b22",
|
|
637
|
+
gridcolor="#21262d", showbackground=True,
|
|
638
|
+
tickfont=dict(color="#8b949e")),
|
|
639
|
+
zaxis=dict(title="Z (axial)", backgroundcolor="#161b22",
|
|
640
|
+
gridcolor="#21262d", showbackground=True,
|
|
641
|
+
tickfont=dict(color="#8b949e")),
|
|
642
|
+
bgcolor="#0d1117",
|
|
643
|
+
camera=dict(eye=dict(x=1.4, y=-1.6, z=1.0)),
|
|
644
|
+
aspectmode="data",
|
|
645
|
+
),
|
|
646
|
+
paper_bgcolor="#0d1117",
|
|
647
|
+
plot_bgcolor="#0d1117",
|
|
648
|
+
font=dict(color="#e6edf3"),
|
|
649
|
+
legend=dict(
|
|
650
|
+
bgcolor="#161b22", bordercolor="#30363d", borderwidth=1,
|
|
651
|
+
font=dict(color="#e6edf3", size=11),
|
|
652
|
+
x=0.01, y=0.99, xanchor="left", yanchor="top",
|
|
653
|
+
),
|
|
654
|
+
margin=dict(l=0, r=0, t=80, b=0),
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
fig.write_html(save_path, include_plotlyjs="cdn", full_html=True)
|
|
658
|
+
print(f" [bolt_pattern] HTML saved → {os.path.abspath(save_path)}")
|
|
659
|
+
|
|
660
|
+
if show:
|
|
661
|
+
fig.show()
|
|
662
|
+
|
|
663
|
+
return fig
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
# ---------------------------------------------------------------------------
|
|
667
|
+
# Console results printer
|
|
668
|
+
# ---------------------------------------------------------------------------
|
|
669
|
+
|
|
670
|
+
def print_results(results: list[BoltResult], analysis: BoltPatternAnalysis) -> None:
|
|
671
|
+
xc, yc = analysis.centroid
|
|
672
|
+
Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = analysis.centroidal_loads()
|
|
673
|
+
max_tension = max(results_1, key=lambda r: r.Fz_total)
|
|
674
|
+
min_tension = min(results_1, key=lambda r: r.Fz_total)
|
|
675
|
+
max_shear = max(results_1, key=lambda r: r.F_shear)
|
|
676
|
+
min_shear = min(results_1, key=lambda r: r.F_shear)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
print("=" * 60)
|
|
680
|
+
print("BOLT PATTERN PROPERTIES")
|
|
681
|
+
print(f" Total area A = {analysis.total_area:.4f}")
|
|
682
|
+
print(f" Centroid = ({xc:.4f}, {yc:.4f})")
|
|
683
|
+
print(f" Ic_x = {analysis.Ic_x:.4f}")
|
|
684
|
+
print(f" Ic_y = {analysis.Ic_y:.4f}")
|
|
685
|
+
print(f" Ic_p (polar) = {analysis.Ic_p:.4f}")
|
|
686
|
+
print()
|
|
687
|
+
print("CENTROIDAL LOADS")
|
|
688
|
+
print(f" Fc_x={Fc_x:.3f} Fc_y={Fc_y:.3f} Fc_z={Fc_z:.3f}")
|
|
689
|
+
print(f" Mc_x={Mc_x:.3f} Mc_y={Mc_y:.3f} Mc_z={Mc_z:.3f}")
|
|
690
|
+
print()
|
|
691
|
+
print("SUMMARY")
|
|
692
|
+
print(f" {'Max fastener tensile load':<28} {max_tension.bolt.label:>6} Fz = {max_tension.Fz_total:>+8.2f}")
|
|
693
|
+
print(f" {'Min fastener tensile load':<28} {min_tension.bolt.label:>6} Fz = {min_tension.Fz_total:>+8.2f}")
|
|
694
|
+
print(f" {'Max fastener shear load':<28} {max_shear.bolt.label:>6} Fs = {max_shear.F_shear:>8.2f}")
|
|
695
|
+
print(f" {'Min fastener shear load':<28} {min_shear.bolt.label:>6} Fs = {min_shear.F_shear:>8.2f}")
|
|
696
|
+
print()
|
|
697
|
+
print("BOLT FORCES")
|
|
698
|
+
hdr = (f"{'Bolt':<10} {'Fz_total':>10} {'Fx_total':>10} "
|
|
699
|
+
f"{'Fy_total':>10} {'F_shear':>10} {'F_total':>10}")
|
|
700
|
+
print(hdr)
|
|
701
|
+
print("-" * 60)
|
|
702
|
+
for r in results:
|
|
703
|
+
label = r.bolt.label or f"({r.bolt.x},{r.bolt.y})"
|
|
704
|
+
print(f"{label:<10} {r.Fz_total:>10.4f} {r.Fx_total:>10.4f} "
|
|
705
|
+
f"{r.Fy_total:>10.4f} {r.F_shear:>10.4f} {r.F_total:>10.4f}")
|
|
706
|
+
print("=" * 60)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# ---------------------------------------------------------------------------
|
|
710
|
+
# Demo / self-test
|
|
711
|
+
# ---------------------------------------------------------------------------
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
if __name__ == "__main__":
|
|
715
|
+
import os
|
|
716
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
717
|
+
|
|
718
|
+
positions_1 = [(-3.5, 15), (3.5, 15), (-3.5, -15), (3.5, -15)
|
|
719
|
+
, (10, -15), (18.82, -12.14), (24.27, -4.635)
|
|
720
|
+
, (24.27, 4.635), (18.82, 12.14), (10, 15)
|
|
721
|
+
, (-10, 15), (-18.82, 12.14), (-24.27, 4.635)
|
|
722
|
+
, (-24.27, -4.635), (-18.82, -12.14), (-10, -15)] #[x,y]
|
|
723
|
+
|
|
724
|
+
# ------------------------------------------------------------------
|
|
725
|
+
# Example 1: 4-bolt rectangular pattern, in-plane eccentric shear
|
|
726
|
+
# A 1000 N force applied in X at (y=5) – creates torsion about Z
|
|
727
|
+
# ------------------------------------------------------------------
|
|
728
|
+
print("\nEXAMPLE 1 – General 3-D loading")
|
|
729
|
+
title="bolt_pattern_ex1"
|
|
730
|
+
analysis_1 = BoltPatternAnalysis(
|
|
731
|
+
bolts=[Bolt(x, y) for (x, y) in positions_1],
|
|
732
|
+
loads=[AppliedLoad(Fx=1500.0, Fy=500.0, Fz=5000.0, z=15.0),
|
|
733
|
+
AppliedLoad(Mz=1000.0)],
|
|
734
|
+
)
|
|
735
|
+
save_dir=os.path.join(HERE)
|
|
736
|
+
results_1 = analysis_1.solve()
|
|
737
|
+
print_results(results_1, analysis_1)
|
|
738
|
+
plot_bolt_pattern_3d(
|
|
739
|
+
analysis_1, results_1,
|
|
740
|
+
title=title,
|
|
741
|
+
show=False,
|
|
742
|
+
save_dir=save_dir,
|
|
743
|
+
)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
|