bolt-pattern-elastic-method 1.0.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 A-Thomas-eng
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.
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: bolt_pattern_elastic_method
3
+ Version: 1.0.0
4
+ Summary: Bolt pattern force distribution analysis
5
+ Project-URL: Homepage, https://github.com/A-Thomas-eng/bolt_pattern_elastic_method
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 A-Thomas-eng
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE.txt
28
+ Requires-Python: >=3.10
29
+ Requires-Dist: matplotlib
30
+ Requires-Dist: numpy
31
+ Description-Content-Type: text/markdown
32
+
33
+ Implements the methodology from NASA RP-1228.
34
+
35
+ Given a bolt pattern (positions + areas) and applied loads at arbitrary
36
+ locations, this module computes the axial and shear force on each bolt.
37
+
38
+ Outputs verified against the calulator here:
39
+ https://mechanicalc.com/reference/bolt-pattern-force-distribution
40
+
41
+ Example usage:
42
+
43
+ if __name__ == "__main__":
44
+ import os
45
+ import bolt_pattern_elastic_method
46
+
47
+ HERE = os.path.dirname(os.path.abspath(__file__))
48
+
49
+ positions_1 = [(-3.5, 15), (3.5, 15), (-3.5, -15), (3.5, -15)
50
+ , (10, -15), (18.82, -12.14), (24.27, -4.635)
51
+ , (24.27, 4.635), (18.82, 12.14), (10, 15)
52
+ , (-10, 15), (-18.82, 12.14), (-24.27, 4.635)
53
+ , (-24.27, -4.635), (-18.82, -12.14), (-10, -15)] #[x,y]
54
+
55
+ # ------------------------------------------------------------------
56
+ # Example 1: 4-bolt rectangular pattern, in-plane eccentric shear
57
+ # A 1000 N force applied in X at (y=5) – creates torsion about Z
58
+ # ------------------------------------------------------------------
59
+ print("\nEXAMPLE 1 – General 3-D loading")
60
+ save_path="bolt_pattern_ex1.png"
61
+ analysis_1 = BoltPatternAnalysis(
62
+ bolts=[Bolt(x, y) for (x, y) in positions_1],
63
+ loads=[AppliedLoad(Fx=1500.0, Fy=500.0, Fz=5000.0, z=15.0),
64
+ AppliedLoad(Mz=1000.0)],
65
+ )
66
+ results_1 = analysis_1.solve()
67
+ print_results(results_1, analysis_1)
68
+ plot_bolt_pattern_3d(
69
+ analysis_1, results_1,
70
+ title="Example 1 - General 3-D loading",
71
+ show=False,
72
+ save_path=os.path.join(HERE, "save_path"),
73
+ )
74
+ print(f" → saved {save_path}")
75
+
76
+
77
+ # ------------------------------------------------------------------
78
+ # Example 2: general 3-D loading using the convenience function
79
+ # ------------------------------------------------------------------
80
+ print("\nEXAMPLE 2 - General 3-D loading (convenience function)")
81
+ analysis_2 = BoltPatternAnalysis(
82
+ bolts=[Bolt(x, y, label=lbl) for (x, y), lbl in zip(
83
+ [(-3, -3), (3, -3), (3, 3), (-3, 3)], ["TL", "TR", "BR", "BL"]
84
+ )],
85
+ loads=[AppliedLoad(Fx=200, Fy=-150, Fz=300,
86
+ Mx=500, My=-400, Mz=1000,
87
+ x=1.0, y=2.0, z=50.0)],
88
+ )
89
+ results_2 = analysis_2.solve()
90
+ print_results(results_2, analysis_2)
91
+ plot_bolt_pattern_3d(
92
+ analysis_2, results_2,
93
+ title="Example 2 - General 3-D Loading",
94
+ show=False,
95
+ save_path=os.path.join(HERE, "bolt_pattern_ex2.png"),
96
+ )
97
+ print(" → saved bolt_pattern_ex2.png")
@@ -0,0 +1,65 @@
1
+ Implements the methodology from NASA RP-1228.
2
+
3
+ Given a bolt pattern (positions + areas) and applied loads at arbitrary
4
+ locations, this module computes the axial and shear force on each bolt.
5
+
6
+ Outputs verified against the calulator here:
7
+ https://mechanicalc.com/reference/bolt-pattern-force-distribution
8
+
9
+ Example usage:
10
+
11
+ if __name__ == "__main__":
12
+ import os
13
+ import bolt_pattern_elastic_method
14
+
15
+ HERE = os.path.dirname(os.path.abspath(__file__))
16
+
17
+ positions_1 = [(-3.5, 15), (3.5, 15), (-3.5, -15), (3.5, -15)
18
+ , (10, -15), (18.82, -12.14), (24.27, -4.635)
19
+ , (24.27, 4.635), (18.82, 12.14), (10, 15)
20
+ , (-10, 15), (-18.82, 12.14), (-24.27, 4.635)
21
+ , (-24.27, -4.635), (-18.82, -12.14), (-10, -15)] #[x,y]
22
+
23
+ # ------------------------------------------------------------------
24
+ # Example 1: 4-bolt rectangular pattern, in-plane eccentric shear
25
+ # A 1000 N force applied in X at (y=5) – creates torsion about Z
26
+ # ------------------------------------------------------------------
27
+ print("\nEXAMPLE 1 – General 3-D loading")
28
+ save_path="bolt_pattern_ex1.png"
29
+ analysis_1 = BoltPatternAnalysis(
30
+ bolts=[Bolt(x, y) for (x, y) in positions_1],
31
+ loads=[AppliedLoad(Fx=1500.0, Fy=500.0, Fz=5000.0, z=15.0),
32
+ AppliedLoad(Mz=1000.0)],
33
+ )
34
+ results_1 = analysis_1.solve()
35
+ print_results(results_1, analysis_1)
36
+ plot_bolt_pattern_3d(
37
+ analysis_1, results_1,
38
+ title="Example 1 - General 3-D loading",
39
+ show=False,
40
+ save_path=os.path.join(HERE, "save_path"),
41
+ )
42
+ print(f" → saved {save_path}")
43
+
44
+
45
+ # ------------------------------------------------------------------
46
+ # Example 2: general 3-D loading using the convenience function
47
+ # ------------------------------------------------------------------
48
+ print("\nEXAMPLE 2 - General 3-D loading (convenience function)")
49
+ analysis_2 = BoltPatternAnalysis(
50
+ bolts=[Bolt(x, y, label=lbl) for (x, y), lbl in zip(
51
+ [(-3, -3), (3, -3), (3, 3), (-3, 3)], ["TL", "TR", "BR", "BL"]
52
+ )],
53
+ loads=[AppliedLoad(Fx=200, Fy=-150, Fz=300,
54
+ Mx=500, My=-400, Mz=1000,
55
+ x=1.0, y=2.0, z=50.0)],
56
+ )
57
+ results_2 = analysis_2.solve()
58
+ print_results(results_2, analysis_2)
59
+ plot_bolt_pattern_3d(
60
+ analysis_2, results_2,
61
+ title="Example 2 - General 3-D Loading",
62
+ show=False,
63
+ save_path=os.path.join(HERE, "bolt_pattern_ex2.png"),
64
+ )
65
+ print(" → saved bolt_pattern_ex2.png")
@@ -0,0 +1 @@
1
+ from .core import Bolt, AppliedLoad, BoltResult, BoltPatternAnalysis, bolt_pattern_force_distribution, plot_bolt_pattern_3d, print_results
@@ -0,0 +1,714 @@
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
+ import matplotlib
27
+ import matplotlib.pyplot as plt
28
+ import matplotlib.patches as mpatches
29
+ import matplotlib.colors as mcolors
30
+ import numpy as np
31
+ from mpl_toolkits.mplot3d import Axes3D # noqa: F401 – registers 3-D projection
32
+ from mpl_toolkits.mplot3d.art3d import Poly3DCollection
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Data structures
37
+ # ---------------------------------------------------------------------------
38
+
39
+ _bolt_counter = 0
40
+
41
+ @dataclass
42
+ class Bolt:
43
+ """A single bolt in the pattern.
44
+
45
+ Parameters
46
+ ----------
47
+ x, y : float
48
+ In-plane position of the bolt.
49
+ area : float
50
+ Tensile stress area of the bolt (default 1.0 for equal-size bolts).
51
+ label : str
52
+ Human-readable name. Auto-assigned as B1, B2, ... if not provided.
53
+ """
54
+ x: float
55
+ y: float
56
+ area: float = 1.0
57
+ label: str = ""
58
+
59
+ def __post_init__(self):
60
+ global _bolt_counter
61
+ if not self.label:
62
+ _bolt_counter += 1
63
+ self.label = f"B{_bolt_counter}"
64
+
65
+
66
+ @dataclass
67
+ class AppliedLoad:
68
+ """A force/moment applied at a specific point.
69
+
70
+ Parameters
71
+ ----------
72
+ Fx, Fy, Fz : float
73
+ Force components (Fz is axial / out-of-plane).
74
+ Mx, My, Mz : float
75
+ Moment components about each axis (right-hand rule).
76
+ x, y, z : float
77
+ Location at which the load is applied.
78
+ The moments are transferred to the pattern centroid automatically.
79
+ """
80
+ Fx: float = 0.0
81
+ Fy: float = 0.0
82
+ Fz: float = 0.0
83
+ Mx: float = 0.0
84
+ My: float = 0.0
85
+ Mz: float = 0.0
86
+ x: float = 0.0
87
+ y: float = 0.0
88
+ z: float = 0.0
89
+
90
+
91
+ @dataclass
92
+ class BoltResult:
93
+ """Forces resolved onto a single bolt."""
94
+ bolt: Bolt
95
+ # Axial (Z-direction) components
96
+ Fz_direct: float = 0.0 # due to direct Fz at centroid
97
+ Fz_Mcx: float = 0.0 # due to centroidal moment about X
98
+ Fz_Mcy: float = 0.0 # due to centroidal moment about Y
99
+ # Shear components
100
+ Fxy_direct_x: float = 0.0 # direct Fx distributed by area
101
+ Fxy_direct_y: float = 0.0 # direct Fy distributed by area
102
+ Fxy_Mcz_x: float = 0.0 # torsional moment – X component
103
+ Fxy_Mcz_y: float = 0.0 # torsional moment – Y component
104
+
105
+ @property
106
+ def Fz_total(self) -> float:
107
+ """Total axial force on this bolt."""
108
+ return self.Fz_direct + self.Fz_Mcx + self.Fz_Mcy
109
+
110
+ @property
111
+ def Fx_total(self) -> float:
112
+ return self.Fxy_direct_x + self.Fxy_Mcz_x
113
+
114
+ @property
115
+ def Fy_total(self) -> float:
116
+ return self.Fxy_direct_y + self.Fxy_Mcz_y
117
+
118
+ @property
119
+ def F_shear(self) -> float:
120
+ """Resultant shear magnitude."""
121
+ return math.hypot(self.Fx_total, self.Fy_total)
122
+
123
+ @property
124
+ def F_total(self) -> float:
125
+ """Total resultant force magnitude."""
126
+ return math.sqrt(self.Fx_total**2 + self.Fy_total**2 + self.Fz_total**2)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Core computation
131
+ # ---------------------------------------------------------------------------
132
+
133
+ @dataclass
134
+ class BoltPatternAnalysis:
135
+ """Analyse force distribution over a bolt pattern.
136
+
137
+ Parameters
138
+ ----------
139
+ bolts : list[Bolt]
140
+ The bolt pattern.
141
+ loads : list[AppliedLoad]
142
+ All applied loads (can be at arbitrary locations).
143
+ """
144
+ bolts: list[Bolt]
145
+ loads: list[AppliedLoad] = field(default_factory=list)
146
+
147
+ # ---- Pattern properties (computed lazily) ----
148
+
149
+ @property
150
+ def total_area(self) -> float:
151
+ """Sum of bolt areas."""
152
+ return sum(b.area for b in self.bolts)
153
+
154
+ @property
155
+ def centroid(self) -> tuple[float, float]:
156
+ """(xc, yc) – area-weighted centroid of the bolt pattern."""
157
+ A = self.total_area
158
+ xc = sum(b.area * b.x for b in self.bolts) / A
159
+ yc = sum(b.area * b.y for b in self.bolts) / A
160
+ return xc, yc
161
+
162
+ @property
163
+ def Ic_x(self) -> float:
164
+ """Second moment of area about the centroidal X-axis (bending about X)."""
165
+ xc, yc = self.centroid
166
+ return sum(b.area * (b.y - yc)**2 for b in self.bolts)
167
+
168
+ @property
169
+ def Ic_y(self) -> float:
170
+ """Second moment of area about the centroidal Y-axis (bending about Y)."""
171
+ xc, yc = self.centroid
172
+ return sum(b.area * (b.x - xc)**2 for b in self.bolts)
173
+
174
+ @property
175
+ def Ic_p(self) -> float:
176
+ """Polar moment of area about the centroid (torsion about Z)."""
177
+ xc, yc = self.centroid
178
+ return sum(b.area * ((b.x - xc)**2 + (b.y - yc)**2) for b in self.bolts)
179
+
180
+ # ---- Translate all loads to the centroid ----
181
+
182
+ def centroidal_loads(self) -> tuple[float, float, float, float, float, float]:
183
+ """Return (Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z) at the pattern centroid.
184
+
185
+ Each applied load is moved to the centroid using the cross-product
186
+ transfer rule: M_new = M_applied + R × F
187
+ where R is the vector from the centroid to the load application point.
188
+ """
189
+ xc, yc = self.centroid
190
+
191
+ Fc_x = Fc_y = Fc_z = 0.0
192
+ Mc_x = Mc_y = Mc_z = 0.0
193
+
194
+ for load in self.loads:
195
+ # Direct force sums
196
+ Fc_x += load.Fx
197
+ Fc_y += load.Fy
198
+ Fc_z += load.Fz
199
+
200
+ # Moment transfer: R = (load.x - xc, load.y - yc, load.z - 0)
201
+ rx = load.x - xc
202
+ ry = load.y - yc
203
+ rz = load.z # centroid is at z=0 by convention
204
+
205
+ # Cross product R × F (right-hand rule)
206
+ cross_x = ry * load.Fz - rz * load.Fy
207
+ cross_y = rz * load.Fx - rx * load.Fz
208
+ cross_z = rx * load.Fy - ry * load.Fx
209
+
210
+ Mc_x += load.Mx + cross_x
211
+ Mc_y += load.My + cross_y
212
+ Mc_z += load.Mz + cross_z
213
+
214
+ return Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z
215
+
216
+ # ---- Distribute to individual bolts ----
217
+
218
+ def solve(self) -> list[BoltResult]:
219
+ """Compute and return the force on each bolt."""
220
+ xc, yc = self.centroid
221
+ A = self.total_area
222
+ Ic_x = self.Ic_x
223
+ Ic_y = self.Ic_y
224
+ Ic_p = self.Ic_p
225
+
226
+ Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = self.centroidal_loads()
227
+
228
+ results = []
229
+ for b in self.bolts:
230
+ r = BoltResult(bolt=b)
231
+
232
+ # Distance of bolt from centroid
233
+ dx = b.x - xc # rc_y component (distance in X from centroid)
234
+ dy = b.y - yc # rc_x component (distance in Y from centroid)
235
+
236
+ # ------------------------------------------------------------------
237
+ # Axial forces (Z direction)
238
+ # ------------------------------------------------------------------
239
+ # 1. Direct Fz distributed proportional to bolt area
240
+ r.Fz_direct = (b.area / A) * Fc_z
241
+
242
+ # 2. Moment Mc_x about X-axis → tensile/compressive along Z
243
+ # F = (Mc_x * rc_x,i / Ic_x) * A_i
244
+ # rc_x,i = dy (distance from centroid in Y direction)
245
+ if abs(Ic_x) > 0:
246
+ r.Fz_Mcx = (Mc_x * dy / Ic_x) * b.area
247
+ else:
248
+ r.Fz_Mcx = 0.0
249
+
250
+ # 3. Moment Mc_y about Y-axis → tensile/compressive along Z
251
+ # rc_y,i = dx (distance from centroid in X direction)
252
+ # Note: sign convention – positive Mc_y acts in -Z for bolts at +X
253
+ if abs(Ic_y) > 0:
254
+ r.Fz_Mcy = -(Mc_y * dx / Ic_y) * b.area
255
+ else:
256
+ r.Fz_Mcy = 0.0
257
+
258
+ # ------------------------------------------------------------------
259
+ # Shear forces (X-Y plane)
260
+ # ------------------------------------------------------------------
261
+ # 1. Direct shear distributed proportional to bolt area
262
+ r.Fxy_direct_x = (b.area / A) * Fc_x
263
+ r.Fxy_direct_y = (b.area / A) * Fc_y
264
+
265
+ # 2. Torsional moment Mc_z – shear perpendicular to radius vector
266
+ # F_i = (Mc_z * r_i / Ic_p) * A_i (magnitude)
267
+ # Direction: perpendicular to (dx, dy), i.e. (-dy, dx) normalised
268
+ if abs(Ic_p) > 0:
269
+ scale = (Mc_z / Ic_p) * b.area
270
+ r.Fxy_Mcz_x = -scale * dy
271
+ r.Fxy_Mcz_y = scale * dx
272
+ else:
273
+ r.Fxy_Mcz_x = 0.0
274
+ r.Fxy_Mcz_y = 0.0
275
+
276
+ results.append(r)
277
+
278
+ return results
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # Convenience function
283
+ # ---------------------------------------------------------------------------
284
+
285
+ def bolt_pattern_force_distribution(
286
+ bolt_positions: Sequence[tuple[float, float]],
287
+ applied_loads: Sequence[dict],
288
+ bolt_areas: Sequence[float] | None = None,
289
+ bolt_labels: Sequence[str] | None = None,
290
+ ) -> list[BoltResult]:
291
+ """High-level helper function.
292
+
293
+ Parameters
294
+ ----------
295
+ bolt_positions : list of (x, y)
296
+ In-plane coordinates of each bolt.
297
+ applied_loads : list of dicts
298
+ Each dict may contain keys: Fx, Fy, Fz, Mx, My, Mz, x, y, z.
299
+ Missing keys default to 0.
300
+ bolt_areas : list of float, optional
301
+ Tensile stress area of each bolt. Defaults to 1.0 for all bolts
302
+ (equal-size bolts, which simplifies area-weighted sums).
303
+ bolt_labels : list of str, optional
304
+ Human-readable names for the bolts.
305
+
306
+ Returns
307
+ -------
308
+ list[BoltResult]
309
+ One entry per bolt with decomposed and total forces.
310
+ """
311
+ n = len(bolt_positions)
312
+ if bolt_areas is None:
313
+ bolt_areas = [1.0] * n
314
+ if bolt_labels is None:
315
+ bolt_labels = [f"Bolt {i+1}" for i in range(n)]
316
+
317
+ bolts = [
318
+ Bolt(x=pos[0], y=pos[1], area=a, label=lbl)
319
+ for pos, a, lbl in zip(bolt_positions, bolt_areas, bolt_labels)
320
+ ]
321
+
322
+ loads = [
323
+ AppliedLoad(
324
+ Fx=ld.get("Fx", 0.0),
325
+ Fy=ld.get("Fy", 0.0),
326
+ Fz=ld.get("Fz", 0.0),
327
+ Mx=ld.get("Mx", 0.0),
328
+ My=ld.get("My", 0.0),
329
+ Mz=ld.get("Mz", 0.0),
330
+ x=ld.get("x", 0.0),
331
+ y=ld.get("y", 0.0),
332
+ z=ld.get("z", 0.0),
333
+ )
334
+ for ld in applied_loads
335
+ ]
336
+
337
+ analysis = BoltPatternAnalysis(bolts=bolts, loads=loads)
338
+ return analysis.solve()
339
+
340
+ # ---------------------------------------------------------------------------
341
+ # 3-D Visualisation
342
+ # ---------------------------------------------------------------------------
343
+
344
+ def plot_bolt_pattern_3d(
345
+ analysis: BoltPatternAnalysis,
346
+ results: list[BoltResult],
347
+ title: str = "Bolt Pattern Force Distribution",
348
+ show: bool = True,
349
+ save_path: str | None = None,
350
+ ) -> plt.Figure:
351
+ """Render a 3-D visualisation of the bolt pattern and its force distribution.
352
+
353
+ The bolt pattern sits in the Z=0 plane. For each bolt three arrows are drawn:
354
+
355
+ * **Blue** - total shear force vector (in the X-Y plane, originating at bolt)
356
+ * **Red** - total axial force (along Z, upward = tension, downward = compression)
357
+ * **Green** - resultant total force vector
358
+
359
+ The centroid is marked with a cross, and each applied load is shown as a
360
+ magenta arrow originating from its application point.
361
+
362
+ Parameters
363
+ ----------
364
+ analysis : BoltPatternAnalysis
365
+ The analysis object (used for centroid, centroidal loads, etc.).
366
+ results : list[BoltResult]
367
+ Per-bolt results from ``analysis.solve()``.
368
+ title : str
369
+ Figure title.
370
+ show : bool
371
+ If True, call ``plt.show()`` at the end.
372
+ save_path : str or None
373
+ If given, save the figure to this path before showing.
374
+
375
+ Returns
376
+ -------
377
+ matplotlib.figure.Figure
378
+ """
379
+ matplotlib.rcParams.update({
380
+ "font.family": "monospace",
381
+ "axes.facecolor": "#0d1117",
382
+ "figure.facecolor": "#0d1117",
383
+ "text.color": "#e6edf3",
384
+ "axes.labelcolor": "#e6edf3",
385
+ "xtick.color": "#8b949e",
386
+ "ytick.color": "#8b949e",
387
+ "grid.color": "#21262d",
388
+ "axes.edgecolor": "#30363d",
389
+ })
390
+
391
+ fig = plt.figure(figsize=(16, 9))
392
+ fig.patch.set_facecolor("#0d1117")
393
+
394
+ # ── Main 3-D axis ────────────────────────────────────────────────────────
395
+ ax3d = fig.add_axes([0.02, 0.08, 0.62, 0.88], projection="3d")
396
+ ax3d.set_facecolor("#0d1117")
397
+ for pane in (ax3d.xaxis.pane, ax3d.yaxis.pane, ax3d.zaxis.pane):
398
+ pane.fill = False
399
+ pane.set_edgecolor("#21262d")
400
+
401
+ xc, yc = analysis.centroid
402
+ Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = analysis.centroidal_loads()
403
+
404
+ # Normalisation scale so arrows are legible regardless of unit magnitudes
405
+ all_magnitudes = [r.F_total for r in results if r.F_total > 0]
406
+ if not all_magnitudes:
407
+ all_magnitudes = [1.0]
408
+ max_mag = max(all_magnitudes)
409
+
410
+ bolt_xs = [b.bolt.x for b in results]
411
+ bolt_ys = [b.bolt.y for b in results]
412
+ span = max(
413
+ max(bolt_xs) - min(bolt_xs),
414
+ max(bolt_ys) - min(bolt_ys),
415
+ 1e-6,
416
+ )
417
+ arrow_scale = span * 0.45 / max_mag # arrows reach ~45 % of the pattern span
418
+
419
+ # ── Colour map: map |F_total| → colour ───────────────────────────────────
420
+ cmap = plt.cm.plasma
421
+ norm = mcolors.Normalize(vmin=0, vmax=max_mag)
422
+
423
+ # ── Draw bolt plate (semi-transparent polygon in Z=0 plane) ──────────────
424
+ # convex hull of bolt positions
425
+ from functools import reduce as _reduce
426
+ import operator as _op
427
+
428
+ pad = span * 0.18
429
+ plate_xs = [min(bolt_xs) - pad, max(bolt_xs) + pad,
430
+ max(bolt_xs) + pad, min(bolt_xs) - pad]
431
+ plate_ys = [min(bolt_ys) - pad, min(bolt_ys) - pad,
432
+ max(bolt_ys) + pad, max(bolt_ys) + pad]
433
+ plate_zs = [0, 0, 0, 0]
434
+ verts = [list(zip(plate_xs, plate_ys, plate_zs))]
435
+ plate = Poly3DCollection(verts, alpha=0.12, facecolor="#58a6ff", edgecolor="#30363d", linewidth=0.8)
436
+ ax3d.add_collection3d(plate)
437
+
438
+ # ── Draw bolts as cylinders ───────────────────────────────────────────────
439
+ theta = np.linspace(0, 2 * np.pi, 32)
440
+ bolt_r = span * 0.04
441
+
442
+ for r in results:
443
+ bx, by = r.bolt.x, r.bolt.y
444
+ color = cmap(norm(r.F_total))
445
+
446
+ # Cylinder body
447
+ cyl_h = span * 0.08
448
+ cx = bx + bolt_r * np.cos(theta)
449
+ cy = by + bolt_r * np.sin(theta)
450
+ z_top = np.full_like(theta, cyl_h)
451
+ z_bot = np.full_like(theta, -cyl_h)
452
+ ax3d.plot_surface(
453
+ np.array([cx, cx]), np.array([cy, cy]),
454
+ np.array([z_bot, z_top]),
455
+ color=color, alpha=0.85, linewidth=0,
456
+ )
457
+ # Top cap
458
+ ax3d.plot_surface(
459
+ np.array([[bx + bolt_r * np.cos(t) for t in theta]]),
460
+ np.array([[by + bolt_r * np.sin(t) for t in theta]]),
461
+ np.full((1, 32), cyl_h),
462
+ color=color, alpha=0.95, linewidth=0,
463
+ )
464
+
465
+ # ── Shear arrow (in-plane) ─────────────────────────────────────────
466
+ fs = r.F_shear
467
+ if fs > 1e-9:
468
+ us = r.Fx_total / fs * fs * arrow_scale
469
+ vs = r.Fy_total / fs * fs * arrow_scale
470
+ ax3d.quiver(bx, by, cyl_h, us, vs, 0,
471
+ color="#388bfd", linewidth=1.8, arrow_length_ratio=0.25)
472
+
473
+ # ── Axial arrow (Z) ────────────────────────────────────────────────
474
+ fz = r.Fz_total
475
+ if abs(fz) > 1e-9:
476
+ wz = fz * arrow_scale
477
+ ax3d.quiver(bx, by, cyl_h, 0, 0, wz,
478
+ color="#f85149" if fz < 0 else "#3fb950",
479
+ linewidth=1.8, arrow_length_ratio=0.25)
480
+
481
+ # ── Resultant arrow ────────────────────────────────────────────────
482
+ ft = r.F_total
483
+ if ft > 1e-9:
484
+ ux = r.Fx_total / ft * ft * arrow_scale
485
+ uy = r.Fy_total / ft * ft * arrow_scale
486
+ uz = r.Fz_total / ft * ft * arrow_scale
487
+ ax3d.quiver(bx, by, cyl_h, ux, uy, uz,
488
+ color="#ffa657", linewidth=1.2,
489
+ arrow_length_ratio=0.2, linestyle="dashed", alpha=0.7)
490
+
491
+ # Label
492
+ lbl = r.bolt.label or f"({bx:.1f},{by:.1f})"
493
+ ax3d.text(bx, by, cyl_h + span * 0.06, lbl,
494
+ color="#e6edf3", fontsize=7, ha="center", va="bottom",
495
+ fontweight="bold")
496
+
497
+ # ── Centroid marker ───────────────────────────────────────────────────────
498
+ ax3d.scatter([xc], [yc], [0], color="#d2a8ff", s=80, zorder=10, marker="+")
499
+ ax3d.text(xc, yc, span * 0.05, "centroid",
500
+ color="#d2a8ff", fontsize=7, ha="center")
501
+
502
+ # ── Applied load arrows (magenta, from their 3-D application point) ──────
503
+ if analysis.loads:
504
+ app_mag = max(
505
+ math.sqrt(l.Fx**2 + l.Fy**2 + l.Fz**2) for l in analysis.loads
506
+ ) or 1.0
507
+ app_scale = span * 0.35 / app_mag
508
+ for ld in analysis.loads:
509
+ fm = math.sqrt(ld.Fx**2 + ld.Fy**2 + ld.Fz**2)
510
+ if fm < 1e-9:
511
+ continue
512
+ ax3d.quiver(ld.x, ld.y, ld.z,
513
+ ld.Fx * app_scale, ld.Fy * app_scale, ld.Fz * app_scale,
514
+ color="#bc8cff", linewidth=2.2, arrow_length_ratio=0.2)
515
+ ax3d.scatter([ld.x], [ld.y], [ld.z], color="#bc8cff", s=40, marker="*")
516
+
517
+ ax3d.set_xlabel("X", labelpad=4, fontsize=9, color="#8b949e")
518
+ ax3d.set_ylabel("Y", labelpad=4, fontsize=9, color="#8b949e")
519
+ ax3d.set_zlabel("Z (axial)", labelpad=4, fontsize=9, color="#8b949e")
520
+ ax3d.set_title(title, color="#e6edf3", fontsize=12, pad=10, fontweight="bold")
521
+ ax3d.view_init(elev=28, azim=-55)
522
+ ax3d.tick_params(colors="#8b949e", labelsize=7)
523
+
524
+ # ── Colour bar ────────────────────────────────────────────────────────────
525
+ sm = plt.cm.ScalarMappable(cmap=cmap, norm=norm)
526
+ sm.set_array([])
527
+ cbar_ax = fig.add_axes([0.65, 0.15, 0.015, 0.65])
528
+ cbar = fig.colorbar(sm, cax=cbar_ax)
529
+ cbar.set_label("|F_total| per bolt", color="#e6edf3", fontsize=9)
530
+ cbar.ax.yaxis.set_tick_params(color="#8b949e", labelsize=7)
531
+ plt.setp(cbar.ax.yaxis.get_ticklabels(), color="#e6edf3")
532
+ cbar.outline.set_edgecolor("#30363d")
533
+
534
+ # ── Bar chart panel (right) ───────────────────────────────────────────────
535
+ ax_bar = fig.add_axes([0.70, 0.55, 0.28, 0.38])
536
+ ax_bar.set_facecolor("#161b22")
537
+ labels = [r.bolt.label or f"B{i+1}" for i, r in enumerate(results)]
538
+ fz_vals = [r.Fz_total for r in results]
539
+ fsh_vals = [r.F_shear for r in results]
540
+ ft_vals = [r.F_total for r in results]
541
+ x_idx = np.arange(len(results))
542
+ w = 0.26
543
+ b1 = ax_bar.bar(x_idx - w, fz_vals, width=w, label="Fz (axial)", color="#3fb950", alpha=0.85)
544
+ b2 = ax_bar.bar(x_idx, fsh_vals, width=w, label="|F_shear|", color="#388bfd", alpha=0.85)
545
+ b3 = ax_bar.bar(x_idx + w, ft_vals, width=w, label="|F_total|", color="#ffa657", alpha=0.85)
546
+ ax_bar.set_xticks(x_idx)
547
+ ax_bar.set_xticklabels(labels, fontsize=7, color="#e6edf3")
548
+ ax_bar.set_ylabel("Force", fontsize=8, color="#8b949e")
549
+ ax_bar.set_title("Per-bolt forces", fontsize=9, color="#e6edf3", fontweight="bold")
550
+ ax_bar.tick_params(colors="#8b949e", labelsize=7)
551
+ ax_bar.spines[:].set_edgecolor("#30363d")
552
+ ax_bar.legend(fontsize=7, facecolor="#0d1117", edgecolor="#30363d",
553
+ labelcolor="#e6edf3", loc="upper left")
554
+ ax_bar.axhline(0, color="#30363d", linewidth=0.8)
555
+
556
+ # ── Centroidal loads table ────────────────────────────────────────────────
557
+ ax_tbl = fig.add_axes([0.70, 0.08, 0.28, 0.40])
558
+ ax_tbl.set_facecolor("#161b22")
559
+ ax_tbl.axis("off")
560
+ col_labels = ["Bolt", "Fz", "Fx", "Fy", "|Fsh|", "|Ft|"]
561
+ rows = [
562
+ [
563
+ r.bolt.label or f"B{i+1}",
564
+ f"{r.Fz_total:+.1f}",
565
+ f"{r.Fx_total:+.1f}",
566
+ f"{r.Fy_total:+.1f}",
567
+ f"{r.F_shear:.1f}",
568
+ f"{r.F_total:.1f}",
569
+ ]
570
+ for i, r in enumerate(results)
571
+ ]
572
+ tbl = ax_tbl.table(
573
+ cellText=rows,
574
+ colLabels=col_labels,
575
+ loc="center",
576
+ cellLoc="center",
577
+ )
578
+ tbl.auto_set_font_size(False)
579
+ tbl.set_fontsize(7.5)
580
+ for (row, col), cell in tbl.get_celld().items():
581
+ cell.set_facecolor("#0d1117" if row % 2 == 0 else "#161b22")
582
+ cell.set_edgecolor("#30363d")
583
+ cell.set_text_props(color="#e6edf3")
584
+ if row == 0:
585
+ cell.set_facecolor("#21262d")
586
+ cell.set_text_props(color="#79c0ff", fontweight="bold")
587
+ ax_tbl.set_title(
588
+ f"Centroid ({xc:.2f}, {yc:.2f}) "
589
+ f"Fc=({Fc_x:.1f}, {Fc_y:.1f}, {Fc_z:.1f}) "
590
+ f"Mc=({Mc_x:.1f}, {Mc_y:.1f}, {Mc_z:.1f})",
591
+ fontsize=7, color="#8b949e", pad=4,
592
+ )
593
+
594
+ # ── Legend for arrows ─────────────────────────────────────────────────────
595
+ legend_elements = [
596
+ mpatches.Patch(color="#388bfd", label="Shear (in-plane)"),
597
+ mpatches.Patch(color="#3fb950", label="Axial +Z (tension)"),
598
+ mpatches.Patch(color="#f85149", label="Axial −Z (compression)"),
599
+ mpatches.Patch(color="#ffa657", label="Resultant"),
600
+ mpatches.Patch(color="#bc8cff", label="Applied load"),
601
+ ]
602
+ fig.legend(
603
+ handles=legend_elements,
604
+ loc="lower center", ncol=5,
605
+ facecolor="#161b22", edgecolor="#30363d",
606
+ labelcolor="#e6edf3", fontsize=8,
607
+ bbox_to_anchor=(0.35, 0.01),
608
+ )
609
+
610
+ plt.suptitle(title, color="#e6edf3", fontsize=13, fontweight="bold", y=0.99)
611
+
612
+ if save_path:
613
+ fig.savefig(save_path, dpi=150, bbox_inches="tight", facecolor=fig.get_facecolor())
614
+ if show:
615
+ plt.show()
616
+
617
+ return fig
618
+
619
+ def print_results(results: list[BoltResult], analysis: BoltPatternAnalysis) -> None:
620
+ xc, yc = analysis.centroid
621
+ Fc_x, Fc_y, Fc_z, Mc_x, Mc_y, Mc_z = analysis.centroidal_loads()
622
+
623
+ print("=" * 60)
624
+ print("BOLT PATTERN PROPERTIES")
625
+ print(f" Total area A = {analysis.total_area:.4f}")
626
+ print(f" Centroid = ({xc:.4f}, {yc:.4f})")
627
+ print(f" Ic_x = {analysis.Ic_x:.4f}")
628
+ print(f" Ic_y = {analysis.Ic_y:.4f}")
629
+ print(f" Ic_p (polar) = {analysis.Ic_p:.4f}")
630
+ print()
631
+ print("CENTROIDAL LOADS")
632
+ print(f" Fc_x={Fc_x:.3f} Fc_y={Fc_y:.3f} Fc_z={Fc_z:.3f}")
633
+ print(f" Mc_x={Mc_x:.3f} Mc_y={Mc_y:.3f} Mc_z={Mc_z:.3f}")
634
+ print()
635
+ print(f"SUMMARY:")
636
+ print(f"Max fastener tensile load: {max(r.Fz_total for r in results):.1f}.")
637
+ print(f"Max fastener shear load: {max(r.F_shear for r in results):.1f}.")
638
+ print()
639
+ print("BOLT FORCES")
640
+ header = f"{'Bolt':<10} {'Fz_total':>10} {'Fx_total':>10} {'Fy_total':>10} {'F_shear':>10} {'F_total':>10}"
641
+ print(header)
642
+ print("-" * 60)
643
+ for r in results:
644
+ label = r.bolt.label or f"({r.bolt.x},{r.bolt.y})"
645
+ print(
646
+ f"{label:<10} "
647
+ f"{r.Fz_total:>10.4f} "
648
+ f"{r.Fx_total:>10.4f} "
649
+ f"{r.Fy_total:>10.4f} "
650
+ f"{r.F_shear:>10.4f} "
651
+ f"{r.F_total:>10.4f}"
652
+ )
653
+ print("=" * 60)
654
+
655
+
656
+ # ---------------------------------------------------------------------------
657
+ # Demo / self-test
658
+ # ---------------------------------------------------------------------------
659
+
660
+
661
+
662
+ # if __name__ == "__main__":
663
+ # import os
664
+ # HERE = os.path.dirname(os.path.abspath(__file__))
665
+
666
+ # positions_1 = [(-3.5, 15), (3.5, 15), (-3.5, -15), (3.5, -15)
667
+ # , (10, -15), (18.82, -12.14), (24.27, -4.635)
668
+ # , (24.27, 4.635), (18.82, 12.14), (10, 15)
669
+ # , (-10, 15), (-18.82, 12.14), (-24.27, 4.635)
670
+ # , (-24.27, -4.635), (-18.82, -12.14), (-10, -15)] #[x,y]
671
+
672
+ # # ------------------------------------------------------------------
673
+ # # Example 1: 4-bolt rectangular pattern, in-plane eccentric shear
674
+ # # A 1000 N force applied in X at (y=5) – creates torsion about Z
675
+ # # ------------------------------------------------------------------
676
+ # print("\nEXAMPLE 1 – General 3-D loading")
677
+ # save_path="bolt_pattern_ex1.png"
678
+ # analysis_1 = BoltPatternAnalysis(
679
+ # bolts=[Bolt(x, y) for (x, y) in positions_1],
680
+ # loads=[AppliedLoad(Fx=1500.0, Fy=500.0, Fz=5000.0, z=15.0),
681
+ # AppliedLoad(Mz=1000.0)],
682
+ # )
683
+ # results_1 = analysis_1.solve()
684
+ # print_results(results_1, analysis_1)
685
+ # plot_bolt_pattern_3d(
686
+ # analysis_1, results_1,
687
+ # title="Example 1 - General 3-D loading",
688
+ # show=False,
689
+ # save_path=os.path.join(HERE, "save_path"),
690
+ # )
691
+ # print(f" → saved {save_path}")
692
+
693
+
694
+ # # ------------------------------------------------------------------
695
+ # # Example 2: general 3-D loading using the convenience function
696
+ # # ------------------------------------------------------------------
697
+ # print("\nEXAMPLE 2 - General 3-D loading (convenience function)")
698
+ # analysis_2 = BoltPatternAnalysis(
699
+ # bolts=[Bolt(x, y, label=lbl) for (x, y), lbl in zip(
700
+ # [(-3, -3), (3, -3), (3, 3), (-3, 3)], ["TL", "TR", "BR", "BL"]
701
+ # )],
702
+ # loads=[AppliedLoad(Fx=200, Fy=-150, Fz=300,
703
+ # Mx=500, My=-400, Mz=1000,
704
+ # x=1.0, y=2.0, z=50.0)],
705
+ # )
706
+ # results_2 = analysis_2.solve()
707
+ # print_results(results_2, analysis_2)
708
+ # plot_bolt_pattern_3d(
709
+ # analysis_2, results_2,
710
+ # title="Example 2 - General 3-D Loading",
711
+ # show=False,
712
+ # save_path=os.path.join(HERE, "bolt_pattern_ex2.png"),
713
+ # )
714
+ # print(" → saved bolt_pattern_ex2.png")
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bolt_pattern_elastic_method"
7
+ version = "1.0.0"
8
+ description = "Bolt pattern force distribution analysis"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE.txt" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "matplotlib",
14
+ "numpy",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://github.com/A-Thomas-eng/bolt_pattern_elastic_method"