13  Cuve sphérique sous pression et gradient thermique — Calcul FEniCSx

Résolution par éléments finis en axisymétrique du problème thermoélastique de la cuve sphérique avec pressurisation et gradient thermique.

13.1 Importation des librairies

import gmsh
import numpy as np
import ufl
from mpi4py import MPI
from dolfinx import mesh, fem, plot, io, cpp
from dolfinx.io import gmshio
from dolfinx.fem.petsc import LinearProblem
import pyvista as pv
pv.OFF_SCREEN = True
import matplotlib.pyplot as plt
from cuve_params import CuveParams

p = CuveParams()
Ri, Re, h = p.R_i, p.R_e, p.h
E, nu, alpha = p.E, p.nu, p.alpha
p_int, T_int, T_ext = p.p_int, p.T_int, p.T_ext
k_therm = p.k

gdim = 2

print(f"Ri={Ri} m, Re={Re} m, h={h} m")
print(f"E={E:.1e} Pa, nu={nu}, alpha={alpha:.1e}")
print(f"p_int={p_int/1e5:.0f} bar")
print(f"T_int={T_int:.0f} C, T_ext={T_ext:.0f} C")
Ri=1.0 m, Re=1.1 m, h=0.1 m
E=2.0e+11 Pa, nu=0.3, alpha=1.2e-05
p_int=350 bar
T_int=-50 C, T_ext=50 C

13.2 Maillage axisymétrique (section méridienne)

On maille la section méridienne de la cuve sphérique : secteur entre deux quart-de-cercles (rayons \(R_i\) et \(R_e\)) avec des quadrangles Q2.

gdim = 2
gmsh.initialize()
gmsh.model.add('cuve')
gmsh.option.setNumber('Mesh.Algorithm', 8)

lc = h / 10.0
p0 = gmsh.model.occ.addPoint(0, 0, 0, lc)
p1 = gmsh.model.occ.addPoint(Ri, 0, 0, lc)
p2 = gmsh.model.occ.addPoint(Re, 0, 0, lc)
p3 = gmsh.model.occ.addPoint(0, Re, 0, lc)
p4 = gmsh.model.occ.addPoint(0, Ri, 0, lc)

l1 = gmsh.model.occ.addCircleArc(p1, p0, p4)
l2 = gmsh.model.occ.addCircleArc(p2, p0, p3)
l3 = gmsh.model.occ.addLine(p1, p2)
l4 = gmsh.model.occ.addLine(p4, p3)

cl = gmsh.model.occ.addCurveLoop([l1, l4, l2, -l3])
s = gmsh.model.occ.addPlaneSurface([cl])
gmsh.model.occ.synchronize()

gmsh.model.mesh.setRecombine(gdim, s)
gmsh.option.setNumber('Mesh.RecombinationAlgorithm', 2)
gmsh.option.setNumber('Mesh.CharacteristicLengthMin', lc)
gmsh.option.setNumber('Mesh.CharacteristicLengthMax', lc)

gmsh.model.addPhysicalGroup(gdim, [s], 1)
gmsh.model.addPhysicalGroup(gdim-1, [l3], 5)
gmsh.model.addPhysicalGroup(gdim-1, [l1], 4)
gmsh.model.addPhysicalGroup(gdim-1, [l2], 3)

gmsh.model.mesh.generate(gdim)
gmsh.model.mesh.optimize('HighOrder')

domain, cell_tags, facet_tags = gmshio.model_to_mesh(gmsh.model, MPI.COMM_WORLD, rank=0, gdim=gdim)

gmsh.finalize()
print(f"Noeuds: {domain.geometry.index_map().size_local}")
print(f"Cellules: {domain.topology.index_map(domain.topology.dim).size_local}")
Info    : Meshing 1D...
Info    : [  0%] Meshing curve 1 (Circle)
Info    : [ 30%] Meshing curve 2 (Circle)
Info    : [ 60%] Meshing curve 3 (Line)
Info    : [ 80%] Meshing curve 4 (Line)
Info    : Done meshing 1D (Wall 0.000235047s, CPU 0.000421s)
Info    : Meshing 2D...
Info    : Meshing surface 1 (Plane, Frontal-Delaunay for Quads)
Info    : Simple recombination completed (Wall 0.00595232s, CPU 0.005952s): 420 quads, 10 triangles, 0 invalid quads, 0 quads with Q < 0.1, avg Q = 0.956369, min Q = 0.604186
Info    : Simple recombination completed (Wall 0.0125802s, CPU 0.01258s): 1710 quads, 0 triangles, 0 invalid quads, 0 quads with Q < 0.1, avg Q = 0.963525, min Q = 0.547972
Info    : Done meshing 2D (Wall 0.0284823s, CPU 0.028426s)
Info    : 1892 nodes 2075 elements
Info    : Optimizing mesh (HighOrder)...
Info    : Optimizing high-order mesh...
Info    : Optimizing mesh...
Info    : Computing connectivity and bad elements for entity 1...
Info    : Starting patch generation from 0 bad elements...
Info    : Constructing 0 primary patches
Info    : Computing patch connectivity...
Info    : Identifying groups of primary patches...
Info    : Merging primary patches into 0 patches...
Info    : Computing boundaries for 0 patches...
Info    : Generated 0 patches
Info    : Optimization succeeded
Info    : Done optimizing mesh (0.00135 s)
Info    : Done optimizing high-order mesh (0.00135 s)
Info    : Done optimizing mesh (Wall 0.00123356s, CPU 0.001464s)
Noeuds: 1891
Cellules: 1710
Warning : Applying high-order mesh optimizer to mesh with linear elements

13.3 Visualisation du maillage

topo, ct, geo = plot.vtk_mesh(domain)
grid = pv.UnstructuredGrid(topo, ct, geo)
plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(grid, show_edges=True, color='lightblue')
plotter.add_axes(color='grey')
plotter.set_background((40/255, 43/255, 48/255, 1.0))
plotter.view_xy()
plotter.show()
plotter.screenshot('mesh.png')

13.4 Espace fonctionnel

On utilise des éléments Q2 (Lagrange quadratiques) pour le déplacement et CG1 (linéaires) pour la température.

V_deg = fem.functionspace(domain, ('CG', 1))  # température
V = fem.functionspace(domain, ('CG', 2, (gdim,)))  # déplacement
Q = fem.functionspace(domain, ('CG', 1, (3,)))  # contrainte DG
print(f"Dofs temperature: {V_deg.dofmap.index_map.size_local}")
print(f"Dofs deplacement: {V.dofmap.index_map.size_local}")
Dofs temperature: 1891
Dofs deplacement: 7201

13.5 Propriétés matériau et constantes

E_expr = fem.Constant(domain, E)
nu_expr = fem.Constant(domain, nu)
alpha_expr = fem.Constant(domain, alpha)
k_expr = fem.Constant(domain, k_therm)

# Tenseur de rigidite (contraintes planes axisymétriques)
# En axisymétrique : epsilon_rr, epsilon_zz, epsilon_tt, gamma_rz
lmbda = E*nu / ((1+nu)*(1-2*nu))
mu = E / (2*(1+nu))
lmbda_expr = fem.Constant(domain, lmbda)
mu_expr = fem.Constant(domain, mu)

print(f"lambda = {lmbda:.2e} Pa, mu = {mu:.2e} Pa")
lambda = 1.15e+11 Pa, mu = 7.69e+10 Pa

13.6 Problème thermique stationnaire

On résout \(\nabla \cdot (k \nabla T) = 0\) avec \(T = T_i\) sur la paroi interne et \(T = T_e\) sur la paroi externe.

T = fem.Function(V_deg, name='Temperature')
T_int_expr = fem.Constant(domain, T_int)
T_ext_expr = fem.Constant(domain, T_ext)

T_trial = ufl.TrialFunction(V_deg)
T_test = ufl.TestFunction(V_deg)
a_T = k_expr * ufl.inner(ufl.grad(T_trial), ufl.grad(T_test)) * ufl.dx
L_T = fem.Constant(domain, 0.0) * T_test * ufl.dx

bdim = domain.topology.dim - 1
domain.topology.create_connectivity(bdim, domain.topology.dim)
facets_int = mesh.locate_entities(domain, bdim, lambda x: np.isclose(x[0]**2 + x[1]**2, Ri**2, rtol=1e-3))
facets_ext = mesh.locate_entities(domain, bdim, lambda x: np.isclose(x[0]**2 + x[1]**2, Re**2, rtol=1e-3))

dofs_T_int = fem.locate_dofs_topological(V_deg, bdim, facets_int)
dofs_T_ext = fem.locate_dofs_topological(V_deg, bdim, facets_ext)

bcs_T = [
    fem.dirichletbc(T_int_expr, dofs_T_int, V_deg),
    fem.dirichletbc(T_ext_expr, dofs_T_ext, V_deg),
]

problem_T = LinearProblem(a_T, L_T, bcs=bcs_T)
T = problem_T.solve()
print(f"Temperature: T(Ri)={T.x.array[dofs_T_int][0]:.2f}, T(Re)={T.x.array[dofs_T_ext][0]:.2f}")
Temperature: T(Ri)=-50.00, T(Re)=50.00

13.7 Visualisation du champ de température

topo, ct, geo = plot.vtk_mesh(V_deg)
grid = pv.UnstructuredGrid(topo, ct, geo)
grid.point_data['T'] = T.x.array

plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(grid, scalars='T', cmap='coolwarm', show_edges=True,
                 scalar_bar_args={'title': 'T (C)', 'title_font_size': 24,
                                  'label_font_size': 18, 'color': 'grey'})
plotter.add_axes(color='grey')
plotter.set_background((40/255, 43/255, 48/255, 1.0))
plotter.view_xy()
plotter.show()
plotter.screenshot('temperature.png')

13.8 Problème thermoélastique

On résout \(\nabla \cdot \boldsymbol{\sigma} = \mathbf{0}\) avec la loi de Duhamel-Neumann \(\boldsymbol{\sigma} = \mathbb{C} : (\boldsymbol{\varepsilon} - \alpha\,\Delta T\,\mathbf{I})\).

En axisymétrique, les déformations s’écrivent : \[\varepsilon_{rr} = \frac{\partial u_r}{\partial r},\quad \varepsilon_{zz} = \frac{\partial u_z}{\partial z},\quad \varepsilon_{\theta\theta} = \frac{u_r}{r},\quad \gamma_{rz} = \frac{\partial u_r}{\partial z} + \frac{\partial u_z}{\partial r}\]

def epsilon(u):
    return ufl.sym(ufl.grad(u))

def epsilon_theta(u):
    return u[0] / ufl.SpatialCoordinate(domain)[0]

def sigma_mech(u):
    r = ufl.SpatialCoordinate(domain)[0]
    eps = epsilon(u)
    tr_eps = ufl.tr(eps) + epsilon_theta(u)
    I = ufl.Identity(2)
    return lmbda_expr * tr_eps * I + 2 * mu_expr * eps

def epsilon_axisym(u):
    r = ufl.SpatialCoordinate(domain)[0]
    eps_2d = epsilon(u)
    e_tt = u[0] / r
    return ufl.as_matrix([[eps_2d[0,0], eps_2d[0,1], 0],
                         [eps_2d[1,0], eps_2d[1,1], 0],
                         [0, 0, e_tt]])

def sigma_3d(u, T, T_ref):
    r = ufl.SpatialCoordinate(domain)[0]
    eps_3d = epsilon_axisym(u)
    tr_eps = ufl.tr(eps_3d)
    I = ufl.Identity(3)
    Delta_T = T - T_ref
    sig = lmbda_expr * tr_eps * I + 2 * mu_expr * eps_3d - (3*lmbda_expr + 2*mu_expr) * alpha_expr * Delta_T * I
    return sig

13.8.1 Cas (a) — Température uniforme \(T = 20\) °C

T_ref = fem.Constant(domain, 0.0)
T_amb = fem.Constant(domain, 20.0)
T_uniform = T_amb

u_a = fem.Function(V, name='Deplacement_a')
du = ufl.TrialFunction(V)
u_test = ufl.TestFunction(V)

a = ufl.inner(sigma_mech(du), epsilon(u_test)) * ufl.dx

I = ufl.Identity(2)
L_therm = ufl.inner((3*lmbda_expr + 2*mu_expr) * alpha_expr * (T_uniform - T_ref) * I, epsilon(u_test)) * ufl.dx

facets_bottom = mesh.locate_entities(domain, bdim, lambda x: np.isclose(x[1], 0))
dofs_uz = fem.locate_dofs_topological(V.sub(1), bdim, facets_bottom)
bc_uz = fem.dirichletbc(fem.Constant(domain, 0.0), dofs_uz, V.sub(1))

problem_a = LinearProblem(a, L_therm, bcs=[bc_uz])
u_a = problem_a.solve()
print("Cas (a): dilatation libre thermique")
Cas (a): dilatation libre thermique

13.8.2 Cas (b) — Gradient thermique \(T(r)\)

u_b = fem.Function(V, name='Deplacement_b')
a = ufl.inner(sigma_mech(du), epsilon(u_test)) * ufl.dx

I = ufl.Identity(2)
L_therm_b = ufl.inner((3*lmbda_expr + 2*mu_expr) * alpha_expr * (T - T_ref) * I, epsilon(u_test)) * ufl.dx

problem_b = LinearProblem(a, L_therm_b, bcs=[bc_uz])
u_b = problem_b.solve()
print("Cas (b): gradient thermique")
Cas (b): gradient thermique

13.8.3 Pression interne seule

u_p = fem.Function(V, name='Deplacement_p')
a = ufl.inner(sigma_mech(du), epsilon(u_test)) * ufl.dx

facets_p = mesh.locate_entities(domain, bdim, lambda x: np.isclose(x[0]**2 + x[1]**2, Ri**2, rtol=1e-3))
mt = mesh.meshtags(domain, bdim, facets_p.astype(np.int32), np.full(len(facets_p), 1, dtype=np.int32))
ds_int = ufl.Measure('ds', domain=domain, subdomain_data=mt, subdomain_id=1)

n_2d = ufl.FacetNormal(domain)
L_p = p_int * ufl.inner(n_2d, u_test) * ds_int

problem_p = LinearProblem(a, L_p, bcs=[bc_uz])
u_p = problem_p.solve()
print('Cas pression: resolue')
Cas pression: resolue

13.9 Post-traitement : extraction des résultats

gdim = 2
coord = domain.geometry.x
r_nodes = np.sqrt(coord[:, 0]**2 + coord[:, 1]**2)

u_p_vals = u_p.x.array.reshape(-1, 2)
ur_p = u_p_vals[:, 0]
uz_p = u_p_vals[:, 1]

print(f"u_r max (pression): {np.max(np.abs(ur_p))*1e3:.4f} mm")
print(f"u_z max (pression): {np.max(np.abs(uz_p))*1e3:.4f} mm\n")

W = fem.functionspace(domain, ('DG', 0, (3,)))
sig_3x3 = sigma_3d(u_p, T_ref, T_ref)
sig_vec = ufl.as_vector([sig_3x3[0,0], sig_3x3[2,2], sig_3x3[1,1]])
sig_p_expr = fem.Expression(sig_vec, W.element.interpolation_points())
sig_p = fem.Function(W, name='Stress_p')
sig_p.interpolate(sig_p_expr)

def vm(srr, stt, szz, srz):
    return np.sqrt(0.5*((srr-stt)**2 + (stt-szz)**2 + (srr-szz)**2 + 6*srz**2))

sig_p_vals = sig_p.x.array.reshape(-1, 3)
vm_p = vm(sig_p_vals[:,0], sig_p_vals[:,1], sig_p_vals[:,2], np.zeros_like(sig_p_vals[:,0]))
print(f"sigma_VM max (pression): {np.max(vm_p)/1e6:.2f} MPa")
print(f"Coef securite: {p.sigma_max/np.max(vm_p):.2f}")
u_r max (pression): 0.0368 mm
u_z max (pression): 0.0352 mm

sigma_VM max (pression): 653.02 MPa
Coef securite: 0.31
## Visualisation du déplacement
topo, ct, geo = plot.vtk_mesh(V)
grid = pv.UnstructuredGrid(topo, ct, geo)
u_vals = u_p.x.array.reshape(-1, 2)
grid.point_data['u_r'] = u_vals[:, 0]
grid.point_data['u_z'] = u_vals[:, 1]
grid.point_data['|u|'] = np.sqrt(u_vals[:, 0]**2 + u_vals[:, 1]**2)

plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(grid, scalars='|u|', cmap='plasma', show_edges=True,
                 scalar_bar_args={'title': '|u| (m)', 'title_font_size': 24,
                                  'label_font_size': 18, 'color': 'grey'})
plotter.add_axes(color='grey')
plotter.set_background((40/255, 43/255, 48/255, 1.0))
plotter.view_xy()
plotter.show()
plotter.screenshot('displacement.png')
topo, ct, geo = plot.vtk_mesh(domain)
grid = pv.UnstructuredGrid(topo, ct, geo)
grid.cell_data['sigma_rr'] = sig_p_vals[:, 0] / 1e6
grid.cell_data['sigma_tt'] = sig_p_vals[:, 1] / 1e6

plotter = pv.Plotter(off_screen=True, shape=(1, 2))
plotter.subplot(0, 0)
plotter.add_mesh(grid, scalars='sigma_rr', cmap='coolwarm', show_edges=True,
                 scalar_bar_args={'title': 'sigma_rr (MPa)', 'title_font_size': 20,
                                  'label_font_size': 14, 'color': 'grey'})
plotter.add_axes(color='grey')
plotter.set_background((40/255, 43/255, 48/255, 1.0))
plotter.view_xy()

plotter.subplot(0, 1)
plotter.add_mesh(grid, scalars='sigma_tt', cmap='coolwarm', show_edges=True,
                 scalar_bar_args={'title': 'sigma_tt (MPa)', 'title_font_size': 20,
                                  'label_font_size': 14, 'color': 'grey'})
plotter.add_axes(color='grey')
plotter.set_background((40/255, 43/255, 48/255, 1.0))
plotter.view_xy()
plotter.show()
plotter.screenshot('stress_profiles.png')
grid.cell_data['sigma_VM'] = vm_p / 1e6

plotter = pv.Plotter(off_screen=True)
plotter.add_mesh(grid, scalars='sigma_VM', cmap='inferno', show_edges=True,
                 scalar_bar_args={'title': 'sigma_VM (MPa)', 'title_font_size': 24,
                                  'label_font_size': 18, 'color': 'grey'})
plotter.add_axes(color='grey')
plotter.set_background((40/255, 43/255, 48/255, 1.0))
plotter.view_xy()
plotter.show()
plotter.screenshot('von_mises.png')

13.10 Comparaison avec la solution analytique de Lamé

r_plot = np.linspace(Ri, Re, 100)

srr_th = CuveParams.sigma_rr_pression(r_plot)
stt_th = CuveParams.sigma_tt_pression(r_plot)
ur_th = CuveParams.u_r_pression(r_plot)

bg = (40/255, 43/255, 48/255, 1.0)

idx = np.argsort(r_nodes)
r_sorted = r_nodes[idx]
ur_sorted = ur_p[idx]

n_cells = domain.topology.index_map(domain.topology.dim).size_local
cell_midpoints = mesh.compute_midpoints(domain, domain.topology.dim, np.arange(n_cells, dtype=np.int32))
r_cells = np.sqrt(cell_midpoints[:, 0]**2 + cell_midpoints[:, 1]**2)
idx_cells = np.argsort(r_cells)
r_cells_sorted = r_cells[idx_cells]

fig, axes = plt.subplots(1, 3, figsize=(16, 5))
fig.patch.set_facecolor(bg)

ax = axes[0]
ax.set_facecolor(bg)
ax.plot(r_sorted, ur_sorted, 'b.', markersize=3, label='FEM')
ax.plot(r_plot, ur_th, 'r-', linewidth=2, label='Lame')
ax.set_xlabel('r (m)', fontsize=13, color='grey')
ax.set_ylabel('u_r (m)', fontsize=13, color='grey')
ax.set_title('Deplacement radial', fontsize=14, color='grey')
ax.legend(fontsize=11); ax.grid(True, alpha=0.3); ax.tick_params(colors='grey')
for sp in ax.spines.values(): sp.set_color('grey')

ax = axes[1]
ax.set_facecolor(bg)
ax.plot(r_cells_sorted, sig_p_vals[idx_cells, 0], 'b.', markersize=3, label=r'FEM $\sigma_{rr}$')
ax.plot(r_cells_sorted, sig_p_vals[idx_cells, 1], 'g.', markersize=3, label=r'FEM $\sigma_{\theta\theta}$')
ax.plot(r_plot, srr_th/1e6, 'r-', linewidth=2, label=r'$\sigma_{rr}$')
ax.plot(r_plot, stt_th/1e6, 'orange', linewidth=2, label=r'$\sigma_{\theta\theta}$')
ax.set_xlabel('r (m)', fontsize=13, color='grey')
ax.set_ylabel('Contrainte (MPa)', fontsize=13, color='grey')
ax.set_title('Contraintes', fontsize=14, color='grey')
ax.legend(fontsize=10); ax.grid(True, alpha=0.3); ax.tick_params(colors='grey')
for sp in ax.spines.values(): sp.set_color('grey')

ax = axes[2]
ax.set_facecolor(bg)
ax.plot(r_cells_sorted, vm_p[idx_cells]/1e6, 'b.', markersize=3, label='FEM')
ax.axhline(p.sigma_max/1e6, color='tab:red', ls='--', lw=1, label=r'$\sigma_{\max}$')
ax.set_xlabel('r (m)', fontsize=13, color='grey')
ax.set_ylabel('$\sigma_{VM}$ (MPa)', fontsize=13, color='grey')
ax.set_title('Von Mises', fontsize=14, color='grey')
ax.legend(fontsize=11); ax.grid(True, alpha=0.3); ax.tick_params(colors='grey')
for sp in ax.spines.values(): sp.set_color('grey')

plt.tight_layout(); plt.show()

13.11 Conclusion

Les résultats FEM axisymétriques sont comparés à la solution analytique de Lamé pour la sphère sous pression. L’accord valide le modèle éléments finis. Le gradient thermique induit des contraintes thermoélastiques qui se superposent aux contraintes de pression.