Skip to content

Commit 4039731

Browse files
Structural Mechanics Calculation for Strain (#49)
* Strain funkcionality * updated docs strings * docs fix * Added tests * sign_von_mises fix * fix based on review * von mises func moved * docs fix * docs format update --------- Co-authored-by: Jan Výborný <142970763+Vybornak2@users.noreply.github.com>
1 parent b91e306 commit 4039731

File tree

3 files changed

+578
-31
lines changed

3 files changed

+578
-31
lines changed

src/fatpy/struct_mech/strain.py

Lines changed: 290 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,292 @@
1-
"""Strain analysis methods of structural mechanics.
1+
"""Calculate fundamental strain metrics and invariants.
22
3-
A group of methods evaluating equivalent strain based on full-tensor input.
3+
These functions provide principal strains, hydrostatic strain, von Mises equivalent
4+
strain, and invariants of the strain tensor. They are essential for strength, fatigue,
5+
and fracture analyses under both uniaxial and multiaxial loading conditions.
6+
7+
Conventions:
8+
- Vectors use Voigt notation with shape (..., 6), where the last dimension
9+
contains the six Voigt components and leading dimensions are preserved:
10+
11+
(ε_11, ε_22, ε_33, ε_23, ε_13, ε_12)
12+
(ε_xx, ε_yy, ε_zz, ε_yz, ε_xz, ε_xy)
13+
14+
- Principal strains are ordered in descending order throughout the module:
15+
ε1 ≥ ε2 ≥ ε3.
16+
- Principal directions (eigenvectors) are aligned to this ordering
17+
(columns correspond to ε1, ε2, ε3).
418
"""
19+
20+
import numpy as np
21+
from numpy.typing import NDArray
22+
23+
from fatpy.utils import voigt
24+
25+
26+
def calc_principal_strains_and_directions(
27+
strain_vector_voigt: NDArray[np.float64],
28+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
29+
r"""Calculate principal strains and principal directions for each state.
30+
31+
??? abstract "Math Equations"
32+
Principal strains and directions are found by solving the eigenvalue problem
33+
for the strain tensor:
34+
35+
$$ \varepsilon \mathbf{v} = \lambda \mathbf{v} $$
36+
37+
Args:
38+
strain_vector_voigt: Array of shape (..., 6). The last dimension contains the
39+
Voigt strain components. Leading dimensions are preserved.
40+
41+
Returns:
42+
Tuple (eigvals, eigvecs):
43+
- eigvals: Array of shape (..., 3). Principal strains
44+
(descending: ε_1 ≥ ε_2 ≥ ε_3) with leading dimensions preserved.
45+
- eigvecs: Array of shape (..., 3, 3). Principal directions (columns are
46+
eigenvectors) aligned with eigvals in the same order. The last two
47+
dimensions are the 3x3 eigenvector matrix for each input.
48+
49+
Raises:
50+
ValueError: If the last dimension is not of size 6.
51+
"""
52+
voigt.check_shape(strain_vector_voigt)
53+
54+
tensor = voigt.voigt_to_tensor(strain_vector_voigt)
55+
eigvals, eigvecs = np.linalg.eigh(tensor)
56+
sorted_indices = np.argsort(eigvals, axis=-1)[..., ::-1]
57+
eigvals_sorted = np.take_along_axis(eigvals, sorted_indices, axis=-1)
58+
eigvecs_sorted = np.take_along_axis(
59+
eigvecs, np.expand_dims(sorted_indices, axis=-2), axis=-1
60+
)
61+
62+
return eigvals_sorted, eigvecs_sorted
63+
64+
65+
def calc_principal_strains(
66+
strain_vector_voigt: NDArray[np.float64],
67+
) -> NDArray[np.float64]:
68+
r"""Calculate principal strains for each strain state.
69+
70+
??? abstract "Math Equations"
71+
Principal strains are found by solving the eigenvalue problem
72+
for the strain tensor:
73+
74+
$$ \varepsilon \mathbf{v} = \lambda \mathbf{v} $$
75+
76+
Args:
77+
strain_vector_voigt: Array of shape (..., 6). The last dimension contains the
78+
Voigt strain components. Leading dimensions are preserved.
79+
80+
Returns:
81+
Array of shape (..., 3). Principal strains (descending: ε1 ≥ ε2 ≥ ε3).
82+
83+
Raises:
84+
ValueError: If the last dimension is not of size 6.
85+
"""
86+
voigt.check_shape(strain_vector_voigt)
87+
88+
tensor = voigt.voigt_to_tensor(strain_vector_voigt)
89+
eigvals = np.linalg.eigvalsh(tensor)
90+
91+
return np.sort(eigvals, axis=-1)[..., ::-1]
92+
93+
94+
def calc_strain_invariants(
95+
strain_vector_voigt: NDArray[np.float64],
96+
) -> NDArray[np.float64]:
97+
r"""Calculate the first, second, and third invariants for each strain state.
98+
99+
??? abstract "Math Equations"
100+
$$
101+
\begin{align*}
102+
I_1 &= tr(\varepsilon), \\
103+
I_2 &= \tfrac{1}{2}\big(I_1^{2} - tr(\varepsilon^{2})\big), \\
104+
I_3 &= \det(\varepsilon)
105+
\end{align*}
106+
$$
107+
108+
Args:
109+
strain_vector_voigt: Array of shape (..., 6). The last dimension contains the
110+
Voigt strain components. Leading dimensions are preserved.
111+
112+
Returns:
113+
Array of shape (..., 3). The last dimension contains (I1, I2, I3) for
114+
each entry.
115+
116+
Raises:
117+
ValueError: If the last dimension is not of size 6.
118+
"""
119+
voigt.check_shape(strain_vector_voigt)
120+
121+
tensor = voigt.voigt_to_tensor(strain_vector_voigt)
122+
invariant_1 = np.trace(tensor, axis1=-2, axis2=-1)
123+
invariant_2 = 0.5 * (
124+
invariant_1**2 - np.trace(np.matmul(tensor, tensor), axis1=-2, axis2=-1)
125+
)
126+
invariant_3 = np.linalg.det(tensor)
127+
128+
return np.stack((invariant_1, invariant_2, invariant_3), axis=-1)
129+
130+
131+
def calc_volumetric_strain(
132+
strain_vector_voigt: NDArray[np.float64],
133+
) -> NDArray[np.float64]:
134+
r"""Calculate the volumetric (mean normal)strain for each strain state.
135+
136+
??? abstract "Math Equations"
137+
$$ \varepsilon_{vol} = \frac{1}{3} \, tr(\varepsilon) =
138+
\frac{1}{3}(\varepsilon_{11} + \varepsilon_{22} + \varepsilon_{33})
139+
$$
140+
141+
Args:
142+
strain_vector_voigt: Array of shape (..., 6). The last dimension contains the
143+
Voigt strain components. Leading dimensions are preserved.
144+
145+
Returns:
146+
Array of shape (...). Volumetric (mean normal) strain for each input state.
147+
Tensor rank is reduced by one.
148+
149+
Raises:
150+
ValueError: If the last dimension is not of size 6.
151+
"""
152+
voigt.check_shape(strain_vector_voigt)
153+
154+
return (
155+
strain_vector_voigt[..., 0]
156+
+ strain_vector_voigt[..., 1]
157+
+ strain_vector_voigt[..., 2]
158+
) / 3.0
159+
160+
161+
def calc_deviatoric_strain(
162+
strain_vector_voigt: NDArray[np.float64],
163+
) -> NDArray[np.float64]:
164+
r"""Calculate the deviatoric strain for each strain state.
165+
166+
??? abstract "Math Equations"
167+
The strain tensor decomposes as:
168+
169+
$$ \varepsilon = \varepsilon_{dev} + \varepsilon_{vol} \mathbf{I} $$
170+
171+
where the deviatoric part is traceless and obtained by subtracting the
172+
volumetric part from the normal components.
173+
174+
$$ \varepsilon_{dev} = \varepsilon - \frac{1}{3} tr(\varepsilon) $$
175+
176+
Args:
177+
strain_vector_voigt: Array of shape (..., 6). The last dimension contains the
178+
Voigt strain components. Leading dimensions are preserved.
179+
180+
Returns:
181+
Array of shape (..., 6). Deviatoric strain for each input state.
182+
183+
Raises:
184+
ValueError: If the last dimension is not of size 6.
185+
"""
186+
voigt.check_shape(strain_vector_voigt)
187+
188+
volumetric = calc_volumetric_strain(strain_vector_voigt)
189+
deviatoric = strain_vector_voigt.copy()
190+
deviatoric[..., :3] = deviatoric[..., :3] - volumetric[..., None]
191+
192+
return deviatoric
193+
194+
195+
# Von Mises functions
196+
def calc_von_mises_strain(
197+
strain_vector_voigt: NDArray[np.float64],
198+
) -> NDArray[np.float64]:
199+
r"""Von Mises equivalent strain computed directly from Voigt components.
200+
201+
??? abstract "Math Equations"
202+
$$
203+
\varepsilon_{vM} = \tfrac{\sqrt{2}}{3}\sqrt{
204+
(\varepsilon_{11}-\varepsilon_{22})^2
205+
+(\varepsilon_{22}-\varepsilon_{33})^2
206+
+(\varepsilon_{33}-\varepsilon_{11})^2
207+
+ 6(\varepsilon_{12}^2+\varepsilon_{23}^2+\varepsilon_{13}^2)}
208+
$$
209+
210+
Args:
211+
strain_vector_voigt: Array of shape (..., 6). The last dimension contains the
212+
Voigt strain components. Leading dimensions are preserved.
213+
214+
Returns:
215+
Array of shape (...). Von Mises equivalent strain for each entry.
216+
Tensor rank is reduced by one.
217+
218+
Raises:
219+
ValueError: If the last dimension is not of size 6.
220+
"""
221+
voigt.check_shape(strain_vector_voigt)
222+
223+
e11 = strain_vector_voigt[..., 0]
224+
e22 = strain_vector_voigt[..., 1]
225+
e33 = strain_vector_voigt[..., 2]
226+
e23 = strain_vector_voigt[..., 3] # epsilon_23
227+
e13 = strain_vector_voigt[..., 4] # epsilon_13
228+
e12 = strain_vector_voigt[..., 5] # epsilon_12
229+
return np.sqrt(
230+
(2.0 / 9.0)
231+
* (
232+
(e11 - e22) ** 2
233+
+ (e22 - e33) ** 2
234+
+ (e33 - e11) ** 2
235+
+ 6.0 * (e12**2 + e23**2 + e13**2)
236+
)
237+
)
238+
239+
240+
def calc_signed_von_mises_by_max_abs_principal(
241+
strain_vector_voigt: NDArray[np.float64],
242+
rtol: float = 1e-5,
243+
atol: float = 1e-8,
244+
) -> NDArray[np.float64]:
245+
r"""Calculate signed von Mises equivalent strain for each strain state.
246+
247+
Sign is determined by average of the maximum and minimum principal strains.
248+
249+
??? note "Sign Convention"
250+
The sign assignment follows these rules:
251+
252+
- **Positive (+)**: When (ε₁ + ε₃)/2 > 0 (tension dominant)
253+
- **Negative (-)**: When (ε₁ + ε₃)/2 < 0 (compression dominant)
254+
- **Positive (+)**: When (ε₁ + ε₃)/2 ≈ 0 (within tolerance, default fallback)
255+
256+
Tolerance parameters ensure numerical stability in edge cases where the
257+
determining value is very close to zero, preventing erratic sign changes
258+
that could occur due to floating-point precision limitations.
259+
260+
??? abstract "Math Equations"
261+
$$
262+
\varepsilon_{SvM} = \begin{cases}
263+
+\varepsilon_{vM} & \text{if } \frac{\varepsilon_1 + \varepsilon_3}{2} \geq 0 \\
264+
-\varepsilon_{vM} & \text{if } \frac{\varepsilon_1 + \varepsilon_3}{2} < 0
265+
\end{cases}
266+
$$
267+
268+
Args:
269+
strain_vector_voigt: Array of shape (..., 6). The last dimension contains the
270+
Voigt strain components. Leading dimensions are preserved.
271+
rtol: Relative tolerance for comparing the average of maximum and minimum
272+
principal strain to zero. Default is 1e-5.
273+
atol: Absolute tolerance for comparing the average of maximum and minimum
274+
principal strain to zero. Default is 1e-8.
275+
276+
Returns:
277+
Array of shape (...). Signed von Mises equivalent strain for each entry.
278+
Tensor rank is reduced by one.
279+
280+
Raises:
281+
ValueError: If the last dimension is not of size 6.
282+
"""
283+
voigt.check_shape(strain_vector_voigt)
284+
285+
von_mises = calc_von_mises_strain(strain_vector_voigt)
286+
principals = calc_principal_strains(strain_vector_voigt)
287+
288+
avg_13 = 0.5 * (principals[..., 0] + principals[..., 2])
289+
sign = np.sign(avg_13).astype(np.float64, copy=False)
290+
sign = np.where(np.isclose(avg_13, 0, rtol=rtol, atol=atol), 1.0, sign)
291+
292+
return sign * von_mises

0 commit comments

Comments
 (0)