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.
Files changed (37) hide show
  1. springcalc/__init__.py +58 -0
  2. springcalc/lineal/__init__.py +0 -0
  3. springcalc/lineal/animation.py +84 -0
  4. springcalc/lineal/compresion.py +540 -0
  5. springcalc/lineal/constants.py +28 -0
  6. springcalc/lineal/extension.py +579 -0
  7. springcalc/lineal/generic_compression.py +472 -0
  8. springcalc/lineal/generic_lineal.py +408 -0
  9. springcalc/lineal/goodman.py +262 -0
  10. springcalc/lineal/lineal.py +225 -0
  11. springcalc/lineal/plotting.py +39 -0
  12. springcalc/lineal/torsion.py +694 -0
  13. springcalc/material/DH_RMa.csv +90 -0
  14. springcalc/material/DIAMETRO_TOLERANCIAS.csv +90 -0
  15. springcalc/material/DM_RMa.csv +75 -0
  16. springcalc/material/SH_RMa.csv +75 -0
  17. springcalc/material/SL_RMa.csv +42 -0
  18. springcalc/material/SM_RMa.csv +75 -0
  19. springcalc/material/__init__.py +0 -0
  20. springcalc/material/materials.csv +22 -0
  21. springcalc/plots/__init__.py +3 -0
  22. springcalc/plots/goodman_diagram.py +57 -0
  23. springcalc/pymodels/__init__.py +0 -0
  24. springcalc/pymodels/material.py +210 -0
  25. springcalc/pymodels/positions.py +260 -0
  26. springcalc/pymodels/units.py +3 -0
  27. springcalc/pymodels/wire_characteristics.py +149 -0
  28. springcalc/regresiones/__init__.py +0 -0
  29. springcalc/regresiones/factor_f/__init__.py +0 -0
  30. springcalc/regresiones/factor_f/factor_f_coeffs.json +10 -0
  31. springcalc/regresiones/factor_f/usar_modelo_factor_f.py +73 -0
  32. springcalc/report/__init__.py +3 -0
  33. springcalc/report/pdf_report.py +240 -0
  34. springcalc-0.1.0.dist-info/METADATA +517 -0
  35. springcalc-0.1.0.dist-info/RECORD +37 -0
  36. springcalc-0.1.0.dist-info/WHEEL +4 -0
  37. 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