Note
Go to the end to download the full example code.
A Neo-Hookean material with the Tensor API
One constitutive law, every measure: this example defines a compressible
Neo-Hookean hyperelastic law and uses the typed Tensor2 / Tensor4
classes to move stresses, strains and tangents between the reference and the
current configuration.
The strain energy is the classical compressible Neo-Hookean potential
which gives closed forms in both configurations:
so every push-forward / pull-back performed with the Tensor API can be checked against an exact analytical result.
Voigt order is the simcoon convention [11, 22, 33, 12, 13, 23].
import numpy as np
import simcoon as sim
0. Material and deformation
A rubber-like compressible Neo-Hookean solid (units: MPa), and a deformation gradient combining stretch and shear so that nothing is orthogonal and J = det(F) != 1 (all the metric factors matter).
E_mod = 10.0 # Young modulus (MPa)
nu = 0.45 # Poisson ratio
mu = E_mod / (2.0 * (1.0 + nu)) # shear modulus
lam = E_mod * nu / ((1.0 + nu) * (1.0 - 2.0 * nu)) # Lame first parameter
F = np.array([[1.30, 0.20, 0.00],
[0.00, 0.95, 0.10],
[0.00, 0.00, 0.90]])
J = np.linalg.det(F)
I3 = np.eye(3)
C = F.T @ F # right Cauchy-Green
b = F @ F.T # left Cauchy-Green
Cinv = np.linalg.inv(C)
print(f"mu = {mu:.4f} MPa, lambda = {lam:.4f} MPa, J = {J:.4f}")
mu = 3.4483 MPa, lambda = 31.0345 MPa, J = 1.1115
1. Strain measures and their transport
Green-Lagrange E lives in the reference configuration, Euler-Almansi e in the current one. They are the same covariant object seen from the two configurations: the strain push-forward F^{-T} E F^{-1} maps one exactly onto the other (no J factor is involved for strains).
E_GL = sim.Tensor2.strain(sim.Green_Lagrange(F))
e_EA = sim.Tensor2.strain(sim.Euler_Almansi(F))
e_from_E = E_GL.push_forward(F)
E_from_e = e_EA.pull_back(F)
print("push_forward(E) == e (Euler-Almansi):",
np.allclose(e_from_E.mat, e_EA.mat))
print("pull_back(e) == E (Green-Lagrange):",
np.allclose(E_from_e.mat, E_GL.mat))
# The logarithmic (Hencky) strain ln V is a third measure, living in the
# current configuration -- it is the one simcoon's solver works with.
h = sim.Tensor2.strain(sim.Log_strain(F))
print("Hencky strain ln V (Voigt):", np.round(h.voigt, 4))
push_forward(E) == e (Euler-Almansi): True
pull_back(e) == E (Green-Lagrange): True
Hencky strain ln V (Voigt): [ 0.2695 -0.0555 -0.1083 0.1503 -0.0072 0.106 ]
2. The law in the reference configuration: PKII stress
S = mu (I - C^{-1}) + lambda ln(J) C^{-1}
S_mat = mu * (I3 - Cinv) + lam * np.log(J) * Cinv
S = sim.Tensor2.stress(S_mat)
print("S (PKII, Voigt):", np.round(S.voigt, 4))
S (PKII, Voigt): [ 3.3447 3.2603 3.2414 0.0289 -0.0034 0.0218]
3. The same law in the current configuration: Kirchhoff stress
tau = mu (b - I) + lambda ln(J) I – this is F S F^T, computed directly.
tau_mat = mu * (b - I3) + lam * np.log(J) * I3
tau = sim.Tensor2.stress(tau_mat)
4. Stress transport: one object, three measures
For a stress the push-forward is the Piola transformation F () F^T, and the
metric flag decides the J factor:
metric=False: S -> tau (Kirchhoff, per reference volume)metric=True: S -> sigma (Cauchy, per current volume)
tau_from_S = S.push_forward(F, metric=False)
sigma_from_S = S.push_forward(F, metric=True)
print("push_forward(S, metric=False) == tau :",
np.allclose(tau_from_S.mat, tau.mat))
print("push_forward(S, metric=True) == tau/J:",
np.allclose(sigma_from_S.mat, tau.mat / J))
print("pull_back(tau) == S (round trip) :",
np.allclose(tau.pull_back(F, metric=False).mat, S.mat))
sigma = sigma_from_S
print("\nCauchy sigma (Voigt):", np.round(sigma.voigt, 4))
print("von Mises (Cauchy):", np.round(sigma.mises(), 4), "MPa")
push_forward(S, metric=False) == tau : True
push_forward(S, metric=True) == tau/J: True
pull_back(tau) == S (round trip) : True
Cauchy sigma (Voigt): [ 5.2163 2.6801 2.3621 0.5894 -0. 0.2792]
von Mises (Cauchy): 2.9353 MPa
5. The material tangent dS/dE
The Neo-Hookean referential tangent is also closed-form:
dS/dE = lambda C^{-1} (x) C^{-1} + 2 (mu - lambda ln J) C^{-1} (.) C^{-1}
where (x) is the dyadic product and (.) the symmetrized dyadic operator
(a (.) a)_{ijkl} = 1/2 (a_ik a_jl + a_il a_jk), available as
sim.auto_sym_dyadic_operator.
cinv_v = sim.t2v_stress(Cinv).ravel() # raw-component Voigt of C^{-1}
dSdE_mat = (lam * np.outer(cinv_v, cinv_v)
+ 2.0 * (mu - lam * np.log(J)) * sim.auto_sym_dyadic_operator(Cinv))
dSdE = sim.Tensor4.stiffness(dSdE_mat)
# Numerical verification by central differences on S(E), perturbing the
# engineering Voigt strain components.
def S_of_E(E_voigt_eng):
C_pert = 2.0 * sim.v2t_strain(E_voigt_eng) + I3
Cinv_pert = np.linalg.inv(C_pert)
J_pert = np.sqrt(np.linalg.det(C_pert))
return sim.t2v_stress(mu * (I3 - Cinv_pert)
+ lam * np.log(J_pert) * Cinv_pert).ravel()
E_v = E_GL.voigt
h_step = 1e-6
dSdE_num = np.empty((6, 6))
for j in range(6):
dE = np.zeros(6)
dE[j] = h_step
dSdE_num[:, j] = (S_of_E(E_v + dE) - S_of_E(E_v - dE)) / (2.0 * h_step)
print("analytic dS/dE == numerical dS/dE:",
np.allclose(dSdE.mat, dSdE_num, rtol=1e-5, atol=1e-6))
# Sanity check: at F = I the tangent must reduce to the isotropic linear
# elastic stiffness with the same Lame constants.
dSdE_at_I = (lam * np.outer(sim.t2v_stress(I3), sim.t2v_stress(I3))
+ 2.0 * mu * sim.auto_sym_dyadic_operator(I3))
print("dS/dE at F=I == L_iso(E, nu):",
np.allclose(dSdE_at_I, sim.L_iso([E_mod, nu], "Enu")))
analytic dS/dE == numerical dS/dE: True
dS/dE at F=I == L_iso(E, nu): True
6. Tangent transport: from dS/dE to the spatial moduli
A stiffness push-forward drags all four legs by F. For the Neo-Hookean tangent the result is again closed-form, because the push-forward of C^{-1} is the identity (F C^{-1} F^T = I):
c_tau = lambda I (x) I + 2 (mu - lambda ln J) I_sym (metric=False)
i.e. the spatial (Lie-rate / Oldroyd) Kirchhoff tangent looks like an
isotropic stiffness with effective Lame constants (lambda, mu - lambda lnJ).
With metric=True everything is divided by J (Cauchy scaling).
c_tau = dSdE.push_forward(F, metric=False)
c_tau_expected = (lam * np.outer(sim.t2v_stress(I3), sim.t2v_stress(I3))
+ 2.0 * (mu - lam * np.log(J)) * sim.auto_sym_dyadic_operator(I3))
print("push_forward(dS/dE, metric=False) == lambda IxI + 2(mu - lambda lnJ) Isym:",
np.allclose(c_tau.mat, c_tau_expected))
c_sigma = dSdE.push_forward(F, metric=True)
print("metric=True is the same tangent / J:",
np.allclose(c_sigma.mat, c_tau.mat / J))
# The round trip back to the reference configuration is exact as well.
print("pull_back(c_tau) == dS/dE (round trip):",
np.allclose(c_tau.pull_back(F, metric=False).mat, dSdE.mat))
# Note: this spatial tangent is conjugate to the Lie (Truesdell/Oldroyd) rate.
# The tangents conjugate to the Jaumann, Green-Naghdi or logarithmic rates add
# a stress-dependent correction (CoRate overload of tensor4::push_forward on
# the C++ side, matching the solver's per-corate dispatch).
push_forward(dS/dE, metric=False) == lambda IxI + 2(mu - lambda lnJ) Isym: True
metric=True is the same tangent / J: True
pull_back(c_tau) == dS/dE (round trip): True
7. Batch: a full uniaxial-stretch curve in three stress measures
The same Tensor calls accept batches. Sweep a confined uniaxial stretch F = diag(lambda_1, 1, 1) (so J = lambda_1 != 1 and the measures separate), build the (N,6) PKII batch with plain numpy, then transport the whole batch with one call.
N = 80
lam1 = np.linspace(0.70, 1.60, N)
F_batch = np.zeros((N, 3, 3))
F_batch[:, 0, 0] = lam1
F_batch[:, 1, 1] = 1.0
F_batch[:, 2, 2] = 1.0
J_batch = lam1
C11 = lam1**2
S11 = mu * (1.0 - 1.0 / C11) + lam * np.log(J_batch) / C11
S22 = lam * np.log(J_batch) # C22 = 1 -> mu term vanishes
S_voigt = np.zeros((N, 6))
S_voigt[:, 0] = S11
S_voigt[:, 1] = S22
S_voigt[:, 2] = S22
S_b = sim.Tensor2.stress(S_voigt) # batch of N PKII tensors
tau_b = S_b.push_forward(F_batch, metric=False) # batch Kirchhoff
sigma_b = S_b.push_forward(F_batch, metric=True) # batch Cauchy
print("batch:", len(S_b), "points, von Mises range:",
np.round(sigma_b.mises().min(), 3), "->",
np.round(sigma_b.mises().max(), 3), "MPa")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(7.0, 4.5))
ax.plot(lam1, S_b.voigt[:, 0], "-.^", ms=3, label="PKII $S_{11}$ (reference)")
ax.plot(lam1, tau_b.voigt[:, 0], ":d", ms=3, label=r"Kirchhoff $\tau_{11}$")
ax.plot(lam1, sigma_b.voigt[:, 0], "-o", ms=3, label=r"Cauchy $\sigma_{11}$")
ax.axhline(0.0, color="k", lw=0.5)
ax.axvline(1.0, color="k", lw=0.5)
ax.set_xlabel(r"Stretch $\lambda_1$ (confined uniaxial, $J = \lambda_1$)")
ax.set_ylabel("Stress (MPa)")
ax.set_title("Neo-Hookean: one law, three stress measures")
ax.grid(True, alpha=0.3)
ax.legend(fontsize=9)
fig.tight_layout()
plt.show()

batch: 80 points, von Mises range: 0.026 -> 3.362 MPa
Total running time of the script: (0 minutes 0.114 seconds)