springcalc 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.
- springcalc/__init__.py +58 -0
- springcalc/lineal/__init__.py +0 -0
- springcalc/lineal/animation.py +84 -0
- springcalc/lineal/compresion.py +540 -0
- springcalc/lineal/constants.py +28 -0
- springcalc/lineal/extension.py +579 -0
- springcalc/lineal/generic_compression.py +472 -0
- springcalc/lineal/generic_lineal.py +408 -0
- springcalc/lineal/goodman.py +262 -0
- springcalc/lineal/lineal.py +225 -0
- springcalc/lineal/plotting.py +39 -0
- springcalc/lineal/torsion.py +694 -0
- springcalc/material/DH_RMa.csv +90 -0
- springcalc/material/DIAMETRO_TOLERANCIAS.csv +90 -0
- springcalc/material/DM_RMa.csv +75 -0
- springcalc/material/SH_RMa.csv +75 -0
- springcalc/material/SL_RMa.csv +42 -0
- springcalc/material/SM_RMa.csv +75 -0
- springcalc/material/__init__.py +0 -0
- springcalc/material/materials.csv +22 -0
- springcalc/plots/__init__.py +3 -0
- springcalc/plots/goodman_diagram.py +57 -0
- springcalc/pymodels/__init__.py +0 -0
- springcalc/pymodels/material.py +210 -0
- springcalc/pymodels/positions.py +260 -0
- springcalc/pymodels/units.py +3 -0
- springcalc/pymodels/wire_characteristics.py +149 -0
- springcalc/regresiones/__init__.py +0 -0
- springcalc/regresiones/factor_f/__init__.py +0 -0
- springcalc/regresiones/factor_f/factor_f_coeffs.json +10 -0
- springcalc/regresiones/factor_f/usar_modelo_factor_f.py +73 -0
- springcalc/report/__init__.py +3 -0
- springcalc/report/pdf_report.py +240 -0
- springcalc-0.1.0.dist-info/METADATA +517 -0
- springcalc-0.1.0.dist-info/RECORD +37 -0
- springcalc-0.1.0.dist-info/WHEEL +4 -0
- springcalc-0.1.0.dist-info/licenses/LICENSE +21 -0
springcalc/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""springcalc — spring calculation library (compression, extension, and torsion).
|
|
2
|
+
|
|
3
|
+
Main public API:
|
|
4
|
+
- CompressionSpring, ExtensionSpring, TorsionSpring: calculations per spring type.
|
|
5
|
+
- Material, get_available_materials: material data.
|
|
6
|
+
- GoodmanData, GoodmanAnalyzer, Goodman: fatigue analysis (Goodman diagram).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from springcalc.lineal.compresion import CompressionSpring
|
|
10
|
+
from springcalc.lineal.generic_compression import CompressionSpringGeneral
|
|
11
|
+
from springcalc.lineal.extension import ExtensionSpring
|
|
12
|
+
from springcalc.lineal.torsion import TorsionSpring
|
|
13
|
+
from springcalc.lineal.animation import CompressionAnimator
|
|
14
|
+
from springcalc.lineal.goodman import Goodman, GoodmanAnalyzer, GoodmanData
|
|
15
|
+
from springcalc.pymodels.material import Material, get_available_materials, get_materials_dataframe
|
|
16
|
+
from springcalc.pymodels.positions import (
|
|
17
|
+
LinearPosition,
|
|
18
|
+
AngularPosition,
|
|
19
|
+
LinearLoadPosition,
|
|
20
|
+
AngularLoadPosition,
|
|
21
|
+
LinearPositionsTable,
|
|
22
|
+
AngularPositionsTable,
|
|
23
|
+
)
|
|
24
|
+
from springcalc.pymodels.wire_characteristics import get_wire_tolerance, get_RMa_range
|
|
25
|
+
from springcalc.plots.goodman_diagram import generate_goodman_diagram
|
|
26
|
+
from springcalc.report import SpringPDFReport
|
|
27
|
+
from springcalc.pymodels.units import ureg
|
|
28
|
+
from springcalc.lineal.plotting import interactive_backend
|
|
29
|
+
from importlib.metadata import version as _version
|
|
30
|
+
|
|
31
|
+
__version__ = _version("springcalc")
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"CompressionSpring",
|
|
35
|
+
"CompressionSpringGeneral",
|
|
36
|
+
"ExtensionSpring",
|
|
37
|
+
"TorsionSpring",
|
|
38
|
+
"CompressionAnimator",
|
|
39
|
+
"Material",
|
|
40
|
+
"get_available_materials",
|
|
41
|
+
"get_materials_dataframe",
|
|
42
|
+
"LinearPosition",
|
|
43
|
+
"AngularPosition",
|
|
44
|
+
"LinearLoadPosition",
|
|
45
|
+
"AngularLoadPosition",
|
|
46
|
+
"LinearPositionsTable",
|
|
47
|
+
"AngularPositionsTable",
|
|
48
|
+
"get_wire_tolerance",
|
|
49
|
+
"get_RMa_range",
|
|
50
|
+
"GoodmanData",
|
|
51
|
+
"GoodmanAnalyzer",
|
|
52
|
+
"Goodman",
|
|
53
|
+
"generate_goodman_diagram",
|
|
54
|
+
"SpringPDFReport",
|
|
55
|
+
"ureg",
|
|
56
|
+
"interactive_backend",
|
|
57
|
+
"__version__",
|
|
58
|
+
]
|
|
File without changes
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Animated GIF generation showing a compression spring's coils closing up
|
|
2
|
+
under progressive load (frame-by-frame 3D helix, driven by
|
|
3
|
+
simulate_progressive_compression's per-step geometry).
|
|
4
|
+
"""
|
|
5
|
+
import numpy as np
|
|
6
|
+
from pint import Quantity
|
|
7
|
+
import matplotlib
|
|
8
|
+
from matplotlib import pyplot as plt
|
|
9
|
+
from matplotlib.animation import FuncAnimation, PillowWriter
|
|
10
|
+
from ..pymodels.units import ureg
|
|
11
|
+
from .generic_lineal import VariableLinealSpring
|
|
12
|
+
matplotlib.use('Agg')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CompressionAnimator:
|
|
16
|
+
"""Renders a spring's progressive compression as an animated GIF.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
animator = CompressionAnimator(spring)
|
|
21
|
+
animator.create_gif(max_deflection=40 * ureg.mm, output_path="compression.gif")
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, spring: VariableLinealSpring):
|
|
25
|
+
self.spring = spring
|
|
26
|
+
|
|
27
|
+
def create_gif(self,
|
|
28
|
+
max_deflection: Quantity,
|
|
29
|
+
output_path: str = "animation.gif",
|
|
30
|
+
steps: int = 60,
|
|
31
|
+
num_points: int = 300,
|
|
32
|
+
fps: int = 12,
|
|
33
|
+
isometric: bool = True) -> str:
|
|
34
|
+
"""Simulate progressive compression and render it as an animated GIF.
|
|
35
|
+
|
|
36
|
+
Returns output_path.
|
|
37
|
+
"""
|
|
38
|
+
deflection, force, _, geometry = self.spring.simulate_progressive_compression(
|
|
39
|
+
max_deflection=max_deflection,
|
|
40
|
+
steps=steps,
|
|
41
|
+
num_points=num_points,
|
|
42
|
+
capture_geometry=True,
|
|
43
|
+
)
|
|
44
|
+
thetas = geometry["thetas"]
|
|
45
|
+
z_history = geometry["z_history"]
|
|
46
|
+
|
|
47
|
+
Ds = np.array([self.spring.f_mean_diameter(z * ureg.mm).to('mm').magnitude for z in z_history[0]])
|
|
48
|
+
radii = Ds / 2.0
|
|
49
|
+
xs = radii * np.cos(thetas)
|
|
50
|
+
ys = radii * np.sin(thetas)
|
|
51
|
+
|
|
52
|
+
deflection_mm = deflection.to('mm').magnitude
|
|
53
|
+
force_n = force.to('N').magnitude
|
|
54
|
+
|
|
55
|
+
fig = plt.figure()
|
|
56
|
+
ax = fig.add_subplot(projection='3d')
|
|
57
|
+
line, = ax.plot(xs, ys, z_history[0])
|
|
58
|
+
|
|
59
|
+
ax.set_xlabel('X (mm)')
|
|
60
|
+
ax.set_ylabel('Y (mm)')
|
|
61
|
+
ax.set_zlabel('Z (mm)')
|
|
62
|
+
# Fix the axis limits up front (from the free-state extent) so the
|
|
63
|
+
# camera/scale doesn't jump around as the coils close up frame to frame.
|
|
64
|
+
margin = max(np.ptp(xs), np.ptp(ys)) * 0.1
|
|
65
|
+
ax.set_xlim(xs.min() - margin, xs.max() + margin)
|
|
66
|
+
ax.set_ylim(ys.min() - margin, ys.max() + margin)
|
|
67
|
+
ax.set_zlim(0, z_history[0].max())
|
|
68
|
+
ax.set_box_aspect((np.ptp(xs), np.ptp(ys), z_history[0].max()))
|
|
69
|
+
if isometric:
|
|
70
|
+
ax.set_proj_type('ortho')
|
|
71
|
+
ax.view_init(elev=35.264, azim=45)
|
|
72
|
+
|
|
73
|
+
def update(frame_idx):
|
|
74
|
+
z = z_history[frame_idx]
|
|
75
|
+
line.set_data(xs, ys)
|
|
76
|
+
line.set_3d_properties(z)
|
|
77
|
+
ax.set_title(f"Deflection: {deflection_mm[frame_idx]:.1f} mm Force: {force_n[frame_idx]:.1f} N")
|
|
78
|
+
return (line,)
|
|
79
|
+
|
|
80
|
+
anim = FuncAnimation(fig, update, frames=len(z_history), interval=1000 / fps)
|
|
81
|
+
anim.save(output_path, writer=PillowWriter(fps=fps))
|
|
82
|
+
plt.close(fig)
|
|
83
|
+
|
|
84
|
+
return output_path
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"""Class for calculating a standard compression spring."""
|
|
2
|
+
from math import pi
|
|
3
|
+
from pydantic import field_validator, ConfigDict
|
|
4
|
+
from .constants import COMPRESSION_SPRING_END_TYPES, FORMING_TYPES
|
|
5
|
+
from .goodman import Goodman
|
|
6
|
+
import traceback
|
|
7
|
+
from matplotlib import pyplot as plt
|
|
8
|
+
from matplotlib.patches import Circle
|
|
9
|
+
import io
|
|
10
|
+
import base64
|
|
11
|
+
import numpy as np
|
|
12
|
+
from .goodman import GoodmanData, GoodmanAnalyzer
|
|
13
|
+
from .lineal import LinealSpring
|
|
14
|
+
from pint import Quantity
|
|
15
|
+
from ..pymodels.units import ureg
|
|
16
|
+
from ..pymodels.positions import LinearPositionsTable
|
|
17
|
+
from typing import Optional
|
|
18
|
+
import matplotlib
|
|
19
|
+
from .plotting import interactive_backend
|
|
20
|
+
matplotlib.use('Agg')
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CompressionSpring(LinealSpring):
|
|
24
|
+
# Additional CompressionSpring fields
|
|
25
|
+
model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
|
|
26
|
+
type_of_end: str = COMPRESSION_SPRING_END_TYPES[1] # ground by default
|
|
27
|
+
# set cold formed by default
|
|
28
|
+
type_conforming: str = FORMING_TYPES[1]
|
|
29
|
+
# the wire length should be in mm
|
|
30
|
+
wire_length: Optional[Quantity] = 0.0 * ureg.mm
|
|
31
|
+
nr_coils: Optional[float] = None
|
|
32
|
+
solid_length: Quantity = 0.0 * ureg.mm # Length at maximum load in mm
|
|
33
|
+
positions: LinearPositionsTable = LinearPositionsTable()
|
|
34
|
+
|
|
35
|
+
@field_validator('wire_length', "solid_length", mode='before')
|
|
36
|
+
@classmethod
|
|
37
|
+
def validate_quantities(cls, value):
|
|
38
|
+
if isinstance(value, str):
|
|
39
|
+
try:
|
|
40
|
+
quantity = ureg(value)
|
|
41
|
+
return quantity.to('mm')
|
|
42
|
+
except Exception as e:
|
|
43
|
+
raise ValueError(f"Error converting '{value}' to Quantity: {e}")
|
|
44
|
+
elif isinstance(value, (int, float)):
|
|
45
|
+
return Quantity(value, 'mm')
|
|
46
|
+
elif isinstance(value, Quantity):
|
|
47
|
+
return value.to('mm')
|
|
48
|
+
else:
|
|
49
|
+
raise ValueError(f"Invalid value for Quantity: {value}")
|
|
50
|
+
|
|
51
|
+
def __init__(self, material, wire_diameter: float, **data):
|
|
52
|
+
"""Initialize the spring variables to 0."""
|
|
53
|
+
# Initialize with default values
|
|
54
|
+
super().__init__(material, wire_diameter, **data)
|
|
55
|
+
|
|
56
|
+
def set_material(self, material, wire_diameter):
|
|
57
|
+
return super().set_material(material, wire_diameter)
|
|
58
|
+
|
|
59
|
+
def set_geometry(self,
|
|
60
|
+
mean_diameter: float = None,
|
|
61
|
+
outer_diameter: float = None,
|
|
62
|
+
inner_diameter: float = None,
|
|
63
|
+
nr_coils: float = None,
|
|
64
|
+
pitch: float = None,
|
|
65
|
+
free_length: float = None,
|
|
66
|
+
type_of_end: Optional[str] = 'closed_unground',
|
|
67
|
+
type_conforming: Optional[str] = 'cold_formed'):
|
|
68
|
+
"""Set the spring's full geometry in one call.
|
|
69
|
+
Provide the general geometry parameters and characteristics of the spring
|
|
70
|
+
Computes and stores every derived spring property and calls calculate_spring_properties().
|
|
71
|
+
Parameters:
|
|
72
|
+
one of the following must be provided: mean_diameter, outer_diameter, inner_diameter
|
|
73
|
+
mean_diameter: mean diameter of the spring (mm)
|
|
74
|
+
outer_diameter: outer diameter of the spring (mm)
|
|
75
|
+
inner_diameter: inner diameter of the spring (mm)
|
|
76
|
+
two of the following must be provided: nr_coils, pitch, free_length
|
|
77
|
+
nr_coils: number of coils in the spring
|
|
78
|
+
pitch: pitch of the spring (mm)
|
|
79
|
+
free_length: free length of the spring (mm)
|
|
80
|
+
Optional parameters:
|
|
81
|
+
type_of_end: type of end of the spring ('open_ground','closed_ground','open_unground','closed_unground')
|
|
82
|
+
type_conforming: type of conforming of the spring ('cold_formed','hot_formed')
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
A dictionary with all the spring properties, including the derived ones.
|
|
86
|
+
"""
|
|
87
|
+
diameters_provided = sum(1 for var in [mean_diameter, outer_diameter, inner_diameter] if var is not None)
|
|
88
|
+
if diameters_provided != 1:
|
|
89
|
+
raise ValueError("You must provide exactly one of the following variables: mean_diameter, outer_diameter, inner_diameter")
|
|
90
|
+
|
|
91
|
+
length_params_provided = sum(1 for var in [nr_coils, pitch, free_length] if var is not None)
|
|
92
|
+
if length_params_provided != 2:
|
|
93
|
+
raise ValueError("You must provide exactly two of the following variables: nr_coils, pitch, free_length")
|
|
94
|
+
|
|
95
|
+
if type_of_end is not None:
|
|
96
|
+
self.type_of_end = type_of_end
|
|
97
|
+
if type_conforming is not None:
|
|
98
|
+
self.type_conforming = type_conforming
|
|
99
|
+
|
|
100
|
+
self.set_diameter(mean_diameter=mean_diameter,
|
|
101
|
+
outer_diameter=outer_diameter,
|
|
102
|
+
inner_diameter=inner_diameter)
|
|
103
|
+
return self.calculate_spring_properties(nr_coils=nr_coils,
|
|
104
|
+
pitch=pitch,
|
|
105
|
+
free_length=free_length)
|
|
106
|
+
|
|
107
|
+
def calculate_spring_properties(self,
|
|
108
|
+
nr_coils: float = None,
|
|
109
|
+
pitch: float = None,
|
|
110
|
+
free_length: float = None):
|
|
111
|
+
"""Part three: Calculate all spring properties."""
|
|
112
|
+
parameters_provided = sum(1 for var in [nr_coils, pitch, free_length] if var is not None)
|
|
113
|
+
if parameters_provided != 2:
|
|
114
|
+
raise ValueError("You must provide exactly two of the following variables: nr_coils, pitch, free_length")
|
|
115
|
+
|
|
116
|
+
if nr_coils is not None and pitch is not None:
|
|
117
|
+
self.nr_coils = nr_coils
|
|
118
|
+
self.pitch = pitch
|
|
119
|
+
self.free_length = self.nr_coils * self.pitch
|
|
120
|
+
elif nr_coils is not None and free_length is not None:
|
|
121
|
+
self.nr_coils = nr_coils
|
|
122
|
+
self.free_length = free_length
|
|
123
|
+
self.pitch = self.free_length / self.nr_coils
|
|
124
|
+
elif pitch is not None and free_length is not None:
|
|
125
|
+
self.pitch = pitch
|
|
126
|
+
self.free_length = free_length
|
|
127
|
+
self.nr_coils = self.free_length / self.pitch
|
|
128
|
+
else:
|
|
129
|
+
raise ValueError("You must provide exactly two of the following variables: nr_coils, pitch, free_length")
|
|
130
|
+
|
|
131
|
+
if self.pitch <= self.wire_diameter:
|
|
132
|
+
raise ValueError("The pitch must be greater than the wire diameter to avoid interference between coils.")
|
|
133
|
+
self.calculate_active_coils(nr_coils=self.nr_coils)
|
|
134
|
+
self.calculate_wahl_factor()
|
|
135
|
+
self.calculate_spring_constant()
|
|
136
|
+
self.calculate_solid_length()
|
|
137
|
+
self.calculate_wire_length()
|
|
138
|
+
self.calculate_load_at_position(self.solid_length)
|
|
139
|
+
self.calculate_outer_diameter_at_position(self.solid_length)
|
|
140
|
+
properties = self.get_spring_data()
|
|
141
|
+
return properties
|
|
142
|
+
|
|
143
|
+
def calculate_active_coils(self, nr_coils):
|
|
144
|
+
"""Calculate the number of active spring coils based on the end type."""
|
|
145
|
+
self.nr_coils = nr_coils
|
|
146
|
+
# Active coils are calculated only for compression springs.
|
|
147
|
+
# For hot-formed springs, 1.5 times the wire diameter is subtracted.
|
|
148
|
+
if self.type_conforming == FORMING_TYPES[1]: # cold formed
|
|
149
|
+
self.nr_active_coils = self.nr_coils - 2
|
|
150
|
+
if self.type_of_end in [COMPRESSION_SPRING_END_TYPES[3], COMPRESSION_SPRING_END_TYPES[4]]: # unground
|
|
151
|
+
self.nr_active_coils -= 1.5
|
|
152
|
+
else:
|
|
153
|
+
# in case spring is hot formed
|
|
154
|
+
self.nr_active_coils = self.nr_coils - 1.5
|
|
155
|
+
if self.type_of_end in [COMPRESSION_SPRING_END_TYPES[1], COMPRESSION_SPRING_END_TYPES[2]]: # ground
|
|
156
|
+
self.nr_active_coils -= 0.3
|
|
157
|
+
else:
|
|
158
|
+
self.nr_active_coils -= 1.1
|
|
159
|
+
return self.nr_active_coils
|
|
160
|
+
|
|
161
|
+
def calculate_wire_length(self):
|
|
162
|
+
"""Calculate the spring wire length."""
|
|
163
|
+
self.wire_length = self.nr_coils * np.sqrt((pi * self.mean_diameter)**2 + self.pitch**2)
|
|
164
|
+
return self.wire_length
|
|
165
|
+
|
|
166
|
+
def calculate_pitch(self):
|
|
167
|
+
"""Calculate the spring pitch."""
|
|
168
|
+
return self.free_length / self.nr_coils
|
|
169
|
+
|
|
170
|
+
def calculate_solid_length(self):
|
|
171
|
+
"""Calculate the spring solid length."""
|
|
172
|
+
self.solid_length = self.nr_coils * self.wire_diameter
|
|
173
|
+
return self.solid_length
|
|
174
|
+
|
|
175
|
+
def add_load_position(self, length: Quantity):
|
|
176
|
+
"""Add a load position to the positions table."""
|
|
177
|
+
if not isinstance(length, Quantity):
|
|
178
|
+
length = length * ureg.mm
|
|
179
|
+
try:
|
|
180
|
+
if length < self.solid_length:
|
|
181
|
+
raise ValueError("The position cannot be smaller than the spring's solid length")
|
|
182
|
+
load = self.calculate_load_at_position(length)
|
|
183
|
+
stress = self.calculate_stress_at_position(load)
|
|
184
|
+
outer_diameter = self.calculate_outer_diameter_at_position(length)
|
|
185
|
+
except ValueError as e:
|
|
186
|
+
raise ValueError(f"Error adding load position at length {length}: {e}")
|
|
187
|
+
self.positions.add_load_position(
|
|
188
|
+
position=self.position_length,
|
|
189
|
+
travel=self.free_length - self.position_length,
|
|
190
|
+
load=load,
|
|
191
|
+
stress=stress,
|
|
192
|
+
outer_diameter=outer_diameter,
|
|
193
|
+
inner_diameter=max(outer_diameter - 2 * self.wire_diameter, 0 * ureg.mm)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def calculate_outer_diameter_at_position(self, length):
|
|
197
|
+
"""Calculate the spring outer diameter."""
|
|
198
|
+
self.position_length = length
|
|
199
|
+
outer_diameter = (self.mean_diameter + self.wire_diameter +
|
|
200
|
+
self.mean_diameter * self.material.poisson_coef *
|
|
201
|
+
(self.free_length - self.position_length) / self.free_length)
|
|
202
|
+
return outer_diameter
|
|
203
|
+
|
|
204
|
+
def empty_tables(self):
|
|
205
|
+
"""Clear the positions, loads, stresses, and outer diameters tables."""
|
|
206
|
+
self.positions.clear_table()
|
|
207
|
+
|
|
208
|
+
def get_spring_data(self):
|
|
209
|
+
"""Return a dictionary with the main spring data."""
|
|
210
|
+
return {
|
|
211
|
+
"material": self.material.material_name,
|
|
212
|
+
"young_modulus": self.material.young_modulus,
|
|
213
|
+
"shear_modulus": self.material.shear_modulus,
|
|
214
|
+
"elastic_limit_factor": self.material.elastic_limit_factor,
|
|
215
|
+
"poisson_coef": self.material.poisson_coef,
|
|
216
|
+
"RMa_file": self.material.RMa_file,
|
|
217
|
+
"wire_diameter": self.wire_diameter,
|
|
218
|
+
"mean_diameter": self.mean_diameter,
|
|
219
|
+
"free_length": self.free_length,
|
|
220
|
+
"nr_coils": self.nr_coils,
|
|
221
|
+
"nr_active_coils": self.nr_active_coils,
|
|
222
|
+
"spring_constant": self.spring_constant,
|
|
223
|
+
"spring_index": self.spring_index,
|
|
224
|
+
"wahl_factor": self.wahl_factor,
|
|
225
|
+
"wahl_factor_category": self.wahl_factor_category,
|
|
226
|
+
"solid_length": self.solid_length,
|
|
227
|
+
"wire_length": self.wire_length,
|
|
228
|
+
"number_cycles": self.number_cycles,
|
|
229
|
+
"shot_peening": self.shot_peening,
|
|
230
|
+
"coating": self.coating
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
def get_data_positions(self):
|
|
234
|
+
"""Return the positions, loads, stresses, and outer diameters table."""
|
|
235
|
+
return self.positions.positions
|
|
236
|
+
|
|
237
|
+
def get_data_travels(self):
|
|
238
|
+
"""Return the positions table for the travel curve."""
|
|
239
|
+
return self.positions.positions
|
|
240
|
+
|
|
241
|
+
def get_forces_vs_position_graph(self, show=False):
|
|
242
|
+
def _to_mm_float(value):
|
|
243
|
+
return float(value.to('mm').magnitude) if isinstance(value, Quantity) else float(value)
|
|
244
|
+
|
|
245
|
+
def _to_n_float(value):
|
|
246
|
+
return float(value.to('N').magnitude) if isinstance(value, Quantity) else float(value)
|
|
247
|
+
|
|
248
|
+
positions_table = self.positions.positions
|
|
249
|
+
positions = [_to_mm_float(pc.position) for pc in positions_table]
|
|
250
|
+
loads = [_to_n_float(pc.load) for pc in positions_table]
|
|
251
|
+
with interactive_backend(show):
|
|
252
|
+
plt.figure()
|
|
253
|
+
plt.plot(positions, loads, marker='o')
|
|
254
|
+
plt.title('Load vs Position Curve')
|
|
255
|
+
plt.xlabel('Position (mm)')
|
|
256
|
+
plt.ylabel('Load (N)')
|
|
257
|
+
plt.grid(True)
|
|
258
|
+
if show:
|
|
259
|
+
plt.show()
|
|
260
|
+
|
|
261
|
+
# Save the plot to a BytesIO object
|
|
262
|
+
buf = io.BytesIO()
|
|
263
|
+
plt.savefig(buf, format='png', dpi=300, bbox_inches='tight')
|
|
264
|
+
buf.seek(0)
|
|
265
|
+
plot_data = base64.b64encode(buf.read()).decode()
|
|
266
|
+
buf.close()
|
|
267
|
+
plt.close()
|
|
268
|
+
|
|
269
|
+
return plot_data
|
|
270
|
+
|
|
271
|
+
def get_forces_vs_travel_graph(self, show=False):
|
|
272
|
+
def _to_mm_float(value):
|
|
273
|
+
return float(value.to('mm').magnitude) if isinstance(value, Quantity) else float(value)
|
|
274
|
+
|
|
275
|
+
def _to_n_float(value):
|
|
276
|
+
return float(value.to('N').magnitude) if isinstance(value, Quantity) else float(value)
|
|
277
|
+
|
|
278
|
+
positions_table = self.positions.positions
|
|
279
|
+
travels = [_to_mm_float(pc.travel) for pc in positions_table]
|
|
280
|
+
loads = [_to_n_float(pc.load) for pc in positions_table]
|
|
281
|
+
with interactive_backend(show):
|
|
282
|
+
plt.figure()
|
|
283
|
+
plt.plot(travels, loads, marker='o')
|
|
284
|
+
plt.title('Load vs Travel Curve')
|
|
285
|
+
plt.xlabel('Travel (mm)')
|
|
286
|
+
plt.ylabel('Load (N)')
|
|
287
|
+
plt.grid(True)
|
|
288
|
+
# Save the plot to a BytesIO object
|
|
289
|
+
if show:
|
|
290
|
+
plt.show()
|
|
291
|
+
buf = io.BytesIO()
|
|
292
|
+
plt.savefig(buf, format='png', dpi=300, bbox_inches='tight')
|
|
293
|
+
buf.seek(0)
|
|
294
|
+
plot_data = base64.b64encode(buf.read()).decode()
|
|
295
|
+
buf.close()
|
|
296
|
+
plt.close()
|
|
297
|
+
|
|
298
|
+
return plot_data
|
|
299
|
+
|
|
300
|
+
def get_diameter_graph(self, show=False):
|
|
301
|
+
def _to_mm_float(value):
|
|
302
|
+
return float(value.to('mm').magnitude) if isinstance(value, Quantity) else float(value)
|
|
303
|
+
|
|
304
|
+
positions_table = self.positions.positions
|
|
305
|
+
positions = [_to_mm_float(pc.position) for pc in positions_table]
|
|
306
|
+
diameters = [_to_mm_float(pc.outer_diameter) for pc in positions_table]
|
|
307
|
+
with interactive_backend(show):
|
|
308
|
+
plt.figure()
|
|
309
|
+
plt.plot(positions, diameters, marker='o', color='orange')
|
|
310
|
+
plt.title('Outer Diameter vs Position')
|
|
311
|
+
plt.xlabel('Position (mm)')
|
|
312
|
+
plt.ylabel('Outer Diameter (mm)')
|
|
313
|
+
plt.grid(True)
|
|
314
|
+
if show:
|
|
315
|
+
plt.show()
|
|
316
|
+
|
|
317
|
+
# Save the plot to a BytesIO object
|
|
318
|
+
buf = io.BytesIO()
|
|
319
|
+
plt.savefig(buf, format='png', dpi=300, bbox_inches='tight')
|
|
320
|
+
buf.seek(0)
|
|
321
|
+
plot_data = base64.b64encode(buf.read()).decode()
|
|
322
|
+
buf.close()
|
|
323
|
+
plt.close()
|
|
324
|
+
|
|
325
|
+
return plot_data
|
|
326
|
+
|
|
327
|
+
def get_diameter_vs_position_graph(self, show=False):
|
|
328
|
+
"""Plot diameter versus position and add a circle diagram."""
|
|
329
|
+
def _to_mm_float(value):
|
|
330
|
+
if isinstance(value, Quantity):
|
|
331
|
+
return float(value.to('mm').magnitude)
|
|
332
|
+
return float(value)
|
|
333
|
+
|
|
334
|
+
positions_table = self.positions.positions
|
|
335
|
+
positions = [_to_mm_float(pc.position) for pc in positions_table]
|
|
336
|
+
diameters = [_to_mm_float(pc.outer_diameter) for pc in positions_table]
|
|
337
|
+
|
|
338
|
+
outer_diameter = self.mean_diameter + self.wire_diameter
|
|
339
|
+
inner_diameter = max(self.mean_diameter - self.wire_diameter, 0 * ureg.mm)
|
|
340
|
+
outer_diameter_mm = _to_mm_float(outer_diameter)
|
|
341
|
+
inner_diameter_mm = _to_mm_float(inner_diameter)
|
|
342
|
+
|
|
343
|
+
with interactive_backend(show):
|
|
344
|
+
fig, (ax1, ax2) = plt.subplots(
|
|
345
|
+
1,
|
|
346
|
+
2,
|
|
347
|
+
figsize=(10, 4),
|
|
348
|
+
gridspec_kw={"width_ratios": [2, 1]}
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
ax1.plot(positions, diameters, marker='o', color='orange')
|
|
352
|
+
ax1.set_title('Outer Diameter vs Position')
|
|
353
|
+
ax1.set_xlabel('Position (mm)')
|
|
354
|
+
ax1.set_ylabel('Outer Diameter (mm)')
|
|
355
|
+
ax1.grid(True)
|
|
356
|
+
|
|
357
|
+
ax2.set_aspect('equal')
|
|
358
|
+
ax2.axis('off')
|
|
359
|
+
|
|
360
|
+
outer_radius = outer_diameter_mm / 2.0
|
|
361
|
+
inner_radius = inner_diameter_mm / 2.0
|
|
362
|
+
max_radius = max(outer_radius, inner_radius, 1.0)
|
|
363
|
+
padding = max_radius * 0.25
|
|
364
|
+
|
|
365
|
+
ax2.add_patch(Circle((0, 0),
|
|
366
|
+
outer_radius,
|
|
367
|
+
fill=False, lw=2,
|
|
368
|
+
color='tab:green'))
|
|
369
|
+
if inner_radius > 0:
|
|
370
|
+
ax2.add_patch(Circle((0, 0),
|
|
371
|
+
inner_radius,
|
|
372
|
+
fill=False,
|
|
373
|
+
lw=2,
|
|
374
|
+
color='tab:blue'))
|
|
375
|
+
|
|
376
|
+
ax2.plot([-outer_radius, outer_radius],
|
|
377
|
+
[0, 0],
|
|
378
|
+
color='tab:green',
|
|
379
|
+
lw=1)
|
|
380
|
+
ax2.text(0,
|
|
381
|
+
-padding,
|
|
382
|
+
f"Dext = {outer_diameter_mm:.2f} mm",
|
|
383
|
+
ha='center',
|
|
384
|
+
va='top',
|
|
385
|
+
fontsize=8)
|
|
386
|
+
|
|
387
|
+
if inner_radius > 0:
|
|
388
|
+
ax2.plot([0, 0],
|
|
389
|
+
[-inner_radius, inner_radius],
|
|
390
|
+
color='tab:blue',
|
|
391
|
+
lw=1)
|
|
392
|
+
ax2.text(0,
|
|
393
|
+
padding,
|
|
394
|
+
f"Dint = {inner_diameter_mm:.2f} mm",
|
|
395
|
+
ha='center',
|
|
396
|
+
va='bottom',
|
|
397
|
+
fontsize=8)
|
|
398
|
+
|
|
399
|
+
ax2.set_xlim(-max_radius - padding, max_radius + padding)
|
|
400
|
+
ax2.set_ylim(-max_radius - padding, max_radius + padding)
|
|
401
|
+
if show:
|
|
402
|
+
plt.show()
|
|
403
|
+
buf = io.BytesIO()
|
|
404
|
+
fig.savefig(buf, format='png', dpi=300, bbox_inches='tight')
|
|
405
|
+
buf.seek(0)
|
|
406
|
+
plot_data = base64.b64encode(buf.read()).decode()
|
|
407
|
+
buf.close()
|
|
408
|
+
plt.close(fig)
|
|
409
|
+
|
|
410
|
+
return plot_data
|
|
411
|
+
|
|
412
|
+
def get_3d_plot(self, num_points: int = 200, show: bool = False, isometric: bool = True) -> str:
|
|
413
|
+
"""Render the spring's helical geometry in 3D (constant mean diameter and pitch).
|
|
414
|
+
|
|
415
|
+
Returns a base64-encoded PNG (same convention as the other graph methods).
|
|
416
|
+
Defaults to an orthographic isometric view, matching how spring drawings
|
|
417
|
+
are conventionally presented.
|
|
418
|
+
"""
|
|
419
|
+
theta_max = 2 * np.pi * self.nr_coils
|
|
420
|
+
thetas = np.linspace(0, theta_max, num_points)
|
|
421
|
+
radius = float(self.mean_diameter.to('mm').magnitude) / 2.0
|
|
422
|
+
pitch_mm = float(self.pitch.to('mm').magnitude)
|
|
423
|
+
|
|
424
|
+
xs = radius * np.cos(thetas)
|
|
425
|
+
ys = radius * np.sin(thetas)
|
|
426
|
+
zs = pitch_mm * thetas / (2 * np.pi)
|
|
427
|
+
|
|
428
|
+
with interactive_backend(show):
|
|
429
|
+
fig = plt.figure()
|
|
430
|
+
ax = fig.add_subplot(projection='3d')
|
|
431
|
+
ax.plot(xs, ys, zs)
|
|
432
|
+
ax.set_xlabel('X (mm)')
|
|
433
|
+
ax.set_ylabel('Y (mm)')
|
|
434
|
+
ax.set_zlabel('Z (mm)')
|
|
435
|
+
ax.set_title('Spring 3D Model')
|
|
436
|
+
ax.set_box_aspect((np.ptp(xs), np.ptp(ys), np.ptp(zs)))
|
|
437
|
+
if isometric:
|
|
438
|
+
ax.set_proj_type('ortho')
|
|
439
|
+
ax.view_init(elev=35.264, azim=45)
|
|
440
|
+
if show:
|
|
441
|
+
plt.show()
|
|
442
|
+
|
|
443
|
+
buf = io.BytesIO()
|
|
444
|
+
fig.savefig(buf, format='png', dpi=300, bbox_inches='tight')
|
|
445
|
+
buf.seek(0)
|
|
446
|
+
plot_data = base64.b64encode(buf.read()).decode()
|
|
447
|
+
buf.close()
|
|
448
|
+
plt.close(fig)
|
|
449
|
+
|
|
450
|
+
return plot_data
|
|
451
|
+
|
|
452
|
+
def create_goodman_diagram(self, show=False):
|
|
453
|
+
"""Generate the Goodman diagram and return a dictionary containing the
|
|
454
|
+
base64 image, analysis, and calculated stresses. If it fails, return a
|
|
455
|
+
dictionary with the 'error' and 'traceback' keys."""
|
|
456
|
+
try:
|
|
457
|
+
if not self.positions.positions:
|
|
458
|
+
raise ValueError("No load positions available for Goodman analysis")
|
|
459
|
+
|
|
460
|
+
# Get stresses from the positions table.
|
|
461
|
+
stress_max = self.get_stress_max()
|
|
462
|
+
stress_min = self.get_stress_min()
|
|
463
|
+
|
|
464
|
+
# Prepare data for Goodman.
|
|
465
|
+
goodman_data = GoodmanData(
|
|
466
|
+
material=self.material,
|
|
467
|
+
diameter=self.wire_diameter,
|
|
468
|
+
load_type='torsion',
|
|
469
|
+
cycles=int(self.number_cycles)
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
analyzer = GoodmanAnalyzer(goodman_data,
|
|
473
|
+
shot_peening=self.shot_peening)
|
|
474
|
+
if show:
|
|
475
|
+
analyzer.plot_diagram(sigma_max=stress_max, sigma_min=stress_min)
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
image_b64 = analyzer.get_diagram_image(sigma_max=stress_max,
|
|
479
|
+
sigma_min=stress_min)
|
|
480
|
+
|
|
481
|
+
analysis = analyzer.get_analysis_summary(stress_max, stress_min)
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
'image': image_b64,
|
|
485
|
+
'analysis': analysis,
|
|
486
|
+
'stresses': {
|
|
487
|
+
'stress_max': round(stress_max, 2),
|
|
488
|
+
'stress_min': round(stress_min, 2),
|
|
489
|
+
'load_max': round(self.get_load_max(), 2),
|
|
490
|
+
'load_min': round(self.get_load_min(), 2)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
except Exception as e:
|
|
494
|
+
tb = traceback.format_exc()
|
|
495
|
+
print(f"Error creating Goodman diagram in LinealSpring: {e}\n{tb}")
|
|
496
|
+
return {'error': str(e), 'traceback': tb}
|
|
497
|
+
|
|
498
|
+
def get_goodman_graph(self, goodman: Goodman, show=True):
|
|
499
|
+
"""Create the Goodman diagram for the spring."""
|
|
500
|
+
# Placeholder implementation
|
|
501
|
+
# The logic for creating the Goodman diagram would be implemented here.
|
|
502
|
+
stress_min = min(self.positions.positions,
|
|
503
|
+
key=lambda x: x.stress).stress
|
|
504
|
+
stress_max = max(self.positions.positions,
|
|
505
|
+
key=lambda x: x.stress).stress
|
|
506
|
+
if show:
|
|
507
|
+
goodman.plot_goodman_graph(stress_max, stress_min)
|
|
508
|
+
else:
|
|
509
|
+
goodman_fig = goodman.get_goodman_graph(stress_max, stress_min)
|
|
510
|
+
return goodman_fig
|
|
511
|
+
|
|
512
|
+
def get_goodman_analysis_summary(self, goodman: Goodman):
|
|
513
|
+
"""Return a complete summary of the Goodman analysis."""
|
|
514
|
+
stress_min = self.get_stress_min()
|
|
515
|
+
stress_max = self.get_stress_max()
|
|
516
|
+
return goodman.get_analysis_summary(stress_max, stress_min)
|
|
517
|
+
|
|
518
|
+
def get_stress_max(self):
|
|
519
|
+
"""Return the maximum spring stress."""
|
|
520
|
+
if not self.positions.positions:
|
|
521
|
+
raise ValueError("No load positions available")
|
|
522
|
+
return max(self.positions.positions, key=lambda x: x.stress).stress
|
|
523
|
+
|
|
524
|
+
def get_stress_min(self):
|
|
525
|
+
"""Return the minimum spring stress."""
|
|
526
|
+
if not self.positions.positions:
|
|
527
|
+
raise ValueError("No load positions available")
|
|
528
|
+
return min(self.positions.positions, key=lambda x: x.stress).stress
|
|
529
|
+
|
|
530
|
+
def get_load_max(self):
|
|
531
|
+
"""Return the maximum spring load."""
|
|
532
|
+
if not self.positions.positions:
|
|
533
|
+
raise ValueError("No load positions available")
|
|
534
|
+
return max(self.positions.positions, key=lambda x: x.load).load
|
|
535
|
+
|
|
536
|
+
def get_load_min(self):
|
|
537
|
+
"""Return the minimum spring load."""
|
|
538
|
+
if not self.positions.positions:
|
|
539
|
+
raise ValueError("No load positions available")
|
|
540
|
+
return min(self.positions.positions, key=lambda x: x.load).load
|