Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Élasticité linéaire

Continuum en petites déformations : 2-D (TRI3 / QUA4) ou 3-D (TET4 / HEX8), les cas 2-D couvrant aussi l’axisymétrie (solide de révolution maillé dans son plan méridien).

Équations continues résolues

Sur le domaine \( \Omega \), avec \( b \) les efforts volumiques :

\[ \underbrace{\nabla\cdot\sigma + b = 0}{\text{équilibre}}, \qquad \underbrace{\sigma = \mathbb{D} : \varepsilon}{\text{loi de Hooke}}, \qquad \underbrace{\varepsilon = \tfrac12(\nabla u + \nabla u^\top)}_{\text{cinématique}}. \]

La forme faible (multiplication par un déplacement virtuel \( v \), intégration par parties) s’écrit : trouver \( u \) tel que pour tout \( v \),

\[ \int_\Omega \varepsilon(v) : \mathbb{D} : \varepsilon(u)\,d\Omega = \int_\Omega v\cdot b\,d\Omega + \int_{\Gamma_N} v\cdot t\,d\Gamma, \]

où \( t \) est la traction imposée sur le bord de Neumann \( \Gamma_N \).

Forme discrétisée

En convention de Voigt (déformation ingénieur \( \gamma = 2\varepsilon \)), le champ discret \( u_h = \sum_i N_i u_i \) donne \( \varepsilon = B\,u_e \), avec la matrice déformation-déplacement \( B \) bâtie des dérivées physiques \( \partial N_i/\partial x_a \) (voir dn_dx). En 2-D (\( \varepsilon = [\varepsilon_{xx}, \varepsilon_{yy}, \gamma_{xy}]^\top \)), le bloc du nœud \( i \) est

\[ B_i = \begin{bmatrix} \partial_x N_i & 0 \\ 0 & \partial_y N_i \\ \partial_y N_i & \partial_x N_i \end{bmatrix}, \]

et en 3-D (\( \varepsilon = [\varepsilon_{xx}, \varepsilon_{yy}, \varepsilon_{zz}, \gamma_{yz}, \gamma_{xz}, \gamma_{xy}]^\top \)),

\[ B_i = \begin{bmatrix} \partial_x N_i & 0 & 0 \\ 0 & \partial_y N_i & 0 \\ 0 & 0 & \partial_z N_i \\ 0 & \partial_z N_i & \partial_y N_i \\ \partial_z N_i & 0 & \partial_x N_i \\ \partial_y N_i & \partial_x N_i & 0 \end{bmatrix}. \]

La rigidité élémentaire est alors, intégrée par quadrature de Gauss,

\[ K_e = \int_{\Omega_e} B^\top D\, B\, d\Omega \;\approx\; \sum_g B(\xi_g)^\top D\, B(\xi_g)\,|J(\xi_g)|\,w_g, \]

écrite aux positions (NodeId_i, f_a) × (NodeId_j, u_b) (ordre des DOFs nœud-majeur). Le second membre nodal cohérent d’une traction de bord est \( f_i = \int_{\Gamma_N} N_i\,t\,d\Gamma \) (opérateur flux).

Matrice constitutive D

Le modèle fixe \( D \) (isotrope, module d’Young \( E \), coefficient de Poisson \( \nu \)) :

  • plane_stress (contraintes planes, \( \sigma_{zz}=0 \)), avec \( c = \dfrac{E}{1-\nu^2} \) :

\[ D = c\begin{bmatrix} 1 & \nu & 0 \\ \nu & 1 & 0 \\ 0 & 0 & \tfrac{1-\nu}{2} \end{bmatrix}; \]

  • plane_strain (déformations planes, \( \varepsilon_{zz}=0 \), \( \sigma_{zz}\neq 0 \)), avec \( c = \dfrac{E}{(1+\nu)(1-2\nu)} \) :

\[ D = c\begin{bmatrix} 1-\nu & \nu & 0 \\ \nu & 1-\nu & 0 \\ 0 & 0 & \tfrac{1-2\nu}{2} \end{bmatrix}; \]

  • full_3d (3-D), même \( c \), avec le module de cisaillement \( G = c\,\tfrac{1-2\nu}{2} \) :

\[ D = \begin{bmatrix} c(1-\nu) & c\nu & c\nu & & & \\ c\nu & c(1-\nu) & c\nu & & & \\ c\nu & c\nu & c(1-\nu) & & & \\ & & & G & & \\ & & & & G & \\ & & & & & G \end{bmatrix} \quad (\text{ordre } [xx, yy, zz, yz, xz, xy]). \]

  • axisymmetric (solide de révolution), même \( c \) : les trois directions normales \( r, z, \theta \) étant orthogonales, le bloc normal est l’isotrope 3×3 et \( rz \) est le seul cisaillement,

\[ D = c\begin{bmatrix} 1-\nu & \nu & \nu & \\ \nu & 1-\nu & \nu & \\ \nu & \nu & 1-\nu & \\ & & & \tfrac{1-2\nu}{2} \end{bmatrix} \quad (\text{ordre } [rr, zz, \theta\theta, rz]). \]

Axisymétrie

Un solide de révolution se maille dans son plan méridien \( (r, z) \) sur des Coords déclarées axisymétriques (\( x = r \ge 0 \), \( y = z \)) — voir Coordonnées. Deux choses changent, et elles ont deux origines distinctes :

  1. la mesure d’intégration, portée par la géométrie : \( d\Omega = 2\pi r\,|J|\,d\xi \). Elle vaut pour toutes les intégrales — rigidité, masse, conductivité, flux réparti, volumes, forces internes, y compris sur les sous-maillages de bord SEG2, dont \( \int 2\pi r\,N \) donne directement l’effort sur l’anneau. Rien à écrire : c’est CellGeom::det_j_w qui l’applique, en un seul point ;
  2. la déformation orthoradiale, portée par le modèle : \( \varepsilon_{\theta\theta} = u_r / r \), que le gradient méridien ne peut pas exprimer. Elle ajoute une quatrième composante de Voigt et une ligne à \( B \) :

\[ B_i = \begin{bmatrix} \partial_r N_i & 0 \\ 0 & \partial_z N_i \\ N_i / r & 0 \\ \partial_z N_i & \partial_r N_i \end{bmatrix} \quad (\varepsilon = [\varepsilon_{rr}, \varepsilon_{zz}, \varepsilon_{\theta\theta}, \gamma_{rz}]^\top). \]

Les points de Gauss étant intérieurs à la maille, \( r > 0 \) même pour un élément qui touche l’axe : le terme \( N_i/r \) reste fini, sans traitement particulier de l’axe.

Nommage (convention Cast3M) : les composantes s’appellent sigma_xx, sigma_yy, sigma_zz, sigma_xy et eps_xx, eps_yy, eps_zz, eps_xy, où zz désigne l’orthoradial \( \theta\theta \) — le plan méridien n’occupant que xx, yy et xy, il n’y a pas de collision.

Le modèle et le repère doivent s’accorder dans les deux sens : une géométrie de révolution refuse plane_stress / plane_strain, et axisymmetric refuse une géométrie cartésienne. Sans cela on mélangerait silencieusement une loi plane avec la mesure \( 2\pi r \).

La thermique n’a rien de spécifique à faire : le flux \( q = -k\nabla T \) est déjà purement méridien, et le facteur \( 2\pi r \) suffit à produire le profil logarithmique d’un cylindre creux. La plasticité et Mazars supportent l’axisymétrie, leur état interne étant déjà stocké en 3-D complet. En revanche barre et portique la refusent : un segment du plan méridien engendre une coque de révolution, que leurs noyaux ne modélisent pas.

Un maillage de bord (SEG2 en 2-D) est par ailleurs refusé comme domaine par les trois physiques de milieu continu : B y serait bâti sur le gradient tangent et \( B^\top D B \) serait déficient en rang dans la direction normale. Un bord porte des charges (flux, convection), il n’est pas un massif.

Validation : tests/axisymmetric.rs (Lamé, patch test de dilatation uniforme, \( \int B^\top\sigma = K u \), volume et masse de révolution, conduction logarithmique) et tests/python/test_axisymmetric.py.

Convergence sur Lamé

La solution de Lamé \( u_r = c_1 r + c_2/r \) comporte un terme rationnel : aucune base de Lagrange, de quelque degré que ce soit, ne la reproduit exactement. Les éléments quadratiques gagnent un ordre, pas l’exactitude. La solution ne dépendant que de \( r \), le problème discret est une EDO 1-D et les valeurs nodales sont superconvergentes en \( O(h^{2p}) \) :

nrQ1 (QUA4)ordreQ2 (QUA8)ordre
56,5e-3—2,6e-5—
101,6e-31,971,7e-63,94
204,1e-41,991,1e-73,99
401,0e-42,006,8e-94,00

(erreur relative maximale sur \( u_r \)). Les contraintes, une dérivée plus bas, passent de \( O(h) \) à \( O(h^2) \).

Le cas exact existe néanmoins : lorsque \( c_2 = 0 \) — dilatation uniforme \( u_r = c\,r \) — l’état de déformation est constant et même Q1 le reproduit à la précision machine (c’est le patch test de la suite de validation).

Matrice de masse

Pour la dynamique, la masse consistante (composante matériau rho) est

\[ M_e = \int_{\Omega_e} \rho\,N^\top N\, d\Omega \;\approx\; \sum_g \rho\,N(\xi_g)^\top N(\xi_g)\,|J(\xi_g)|\,w_g, \]

où \( N \) place \( N_i \) sur chaque composante de translation — assemblée par assemble.mass, et concentrable en diagonale par lump.

Variables et matériau

  • primal : u_x, u_y(, u_z) — dual : f_x, f_y(, f_z).
  • matériau : E (Young), nu (Poisson) ; facultatif alpha (dilatation thermique, cf. thermomécanique), rho (masse) — accepté par le champ matériau mais jamais exigé pour un assemblage purement élastique.
  • comportement (COMP) : σ = D ε (convention tenseur → ingénieur γ = 2ε), à partir de la déformation ε (op deformation).
  • modèles : plane_stress, plane_strain, axisymmetric (2-D) et full_3d (3-D).

Mise en donnée (Rust, testé)

Carré unité en contraintes planes : appuis u_x = 0 (gauche) et u_y = 0 (bas), traction S sur le bord droit appliquée en charges nodales cohérentes par l’opérateur flux (composante f_x). Solution exacte u_x = (S/E)·x, u_y = −(ν S/E)·y. Code = test tests/elasticity.rs (le fichier contient aussi un test 3-D sur un cube HEX8) :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::finite_element_space::FiniteElementSpace;
use pyrucast::containers::mesh::{Mesh, SubMesh};
use pyrucast::containers::model::Model;
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::solve;
use pyrucast::Result;

#[test]
fn elasticity_unit_square_uniaxial_tension() -> Result<()> {
    const E: f64 = 210.0; // Young's modulus
    const NU: f64 = 0.3; // Poisson's ratio
    const S: f64 = 2.0; // traction on the right edge
    const N: usize = 2; // N×N QUA4 grid
    let h = 1.0 / N as f64;

    // ── QUA4 mesh on [0,1]² ────────────────────────────────────────────────
    let coords = Handle::new(Coords::new(2)?);
    let idx = |i: usize, j: usize| j * (N + 1) + i;
    let mut grid: Vec<Node> = Vec::new();
    for j in 0..=N {
        for i in 0..=N {
            grid.push(Node::create_in(
                coords.clone(),
                &[i as f64 * h, j as f64 * h],
            )?);
        }
    }
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::QUA4));
    for j in 0..N {
        for i in 0..N {
            mesh.add_cell(&[
                grid[idx(i, j)].id(),
                grid[idx(i + 1, j)].id(),
                grid[idx(i + 1, j + 1)].id(),
                grid[idx(i, j + 1)].id(),
            ])?;
        }
    }
    let fes = FiniteElementSpace::lagrange1(&mesh)?;

    // ── Modèle : élasticité plane stress + appuis (rollers) ────────────────
    // The support receives the model it constrains: the dual is read there, and
    // contraints s'y vérifient.
    let roller = |target: &Model, nodes: &[Node], var: &str| -> Result<Model> {
        let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(nodes)?);
        let multiplier = mesh::barycenter(&imposed)?;
        model::dirichlet(target, var, &imposed, &multiplier, Default::default())
    };
    let left: Vec<Node> = (0..=N).map(|j| grid[idx(0, j)].clone()).collect();
    let bottom: Vec<Node> = (0..=N).map(|i| grid[idx(i, 0)].clone()).collect();
    let mut model = model::elasticity(&fes, Kinematics::PlaneStress)?;
    model = model.union(&roller(&model, &left, "u_x")?)?;
    model = model.union(&roller(&model, &bottom, "u_y")?)?;

    // ── Loading: traction S on the right edge (consistent nodal loads, on the
    //    f_x component). The load is a sub-model: it joins the model, its density
    //    the material. ──────────────────────────────────────────────────────
    let mut right_edge = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    for j in 0..N {
        right_edge.add_cell(&[grid[idx(N, j)].id(), grid[idx(N, j + 1)].id()])?;
    }
    let right_fes = FiniteElementSpace::lagrange1(&right_edge)?;
    model = model.union(&model::flux(&right_fes, &model, "f_x".into())?)?;

    let materials = pyrucast::ops::element_field::material_field(
        &model,
        &[("E", E), ("nu", NU), ("phi_f_x", S)],
    )?;
    let rhs = pyrucast::ops::node_field::external_forces(&model, &materials)?;

    // ── Assemblage + résolution ────────────────────────────────────────────
    let stiffness = pyrucast::ops::matrix::stiffness(&model, &materials)?;
    let solution = solve(&stiffness, &rhs)?;

    // ── Compared with the analytical u_x = (S/E)·x, u_y = −(ν S/E)·y ───────
    let tol = 1e-10;
    for j in 0..=N {
        for i in 0..=N {
            let (x, y) = (i as f64 * h, j as f64 * h);
            let ux = solution.value(grid[idx(i, j)].id(), "u_x")?;
            let uy = solution.value(grid[idx(i, j)].id(), "u_y")?;
            assert!((ux - S / E * x).abs() < tol, "u_x({x},{y}) = {ux}");
            assert!((uy + NU * S / E * y).abs() < tol, "u_y({x},{y}) = {uy}");
        }
    }
    Ok(())
}

Exemple Python

"""Élasticité linéaire — traction d'un carré (contraintes planes).

Physique
--------
Continuum en petites déformations : équilibre `∇·σ = 0`, loi de Hooke
`σ = D : ε`, kinematics `ε = ½(∇u + ∇uᵀ)`. The stiffness is
`K = ∫ Bᵀ D B dΩ` (B : matrice déformation-déplacement en Voigt, D : matrice
constitutive isotrope, ici en contraintes planes).

Problème
--------
Carré unité, appuis `u_x = 0` (bord gauche) et `u_y = 0` (bord bas), traction
`S` on the right edge applied as consistent nodal loads by the `flux` operator
(on the `f_x` component). Exact (uniaxial) solution:
`u_x = (S/E)·x`, `u_y = -(ν·S/E)·y`.

Lancement ::

    maturin develop --features extension-module
    python examples/elasticity.py
"""

import pyrucast

E, NU, S, N = 210.0, 0.3, 2.0, 2


def _clamp(target, nodes, var):
    imposed = pyrucast.mesh.poi1_from_nodes(nodes)
    multiplier = pyrucast.mesh.barycenter(imposed)
    return pyrucast.model.dirichlet(target, var, imposed, multiplier)


def main() -> None:
    h = 1.0 / N
    c = pyrucast.Coords(2)

    def idx(i, j):
        return j * (N + 1) + i

    # An N×N grid of QUA4 by sweeping two SEG2 lines (`sweep`).
    bottom = pyrucast.mesh.line(c.add_node([0.0, 0.0]), c.add_node([1.0, 0.0]), N)
    top = pyrucast.mesh.line(c.add_node([0.0, 1.0]), c.add_node([1.0, 1.0]), N)
    mesh = pyrucast.mesh.sweep(bottom, top, N)

    # Nodes laid out by idx(i, j) (i along x, j along y) by reading the
    # connectivité QUA4 : maille (cy, cx) = cy*N + cx, nœuds locaux 0..3.
    grid = [None] * ((N + 1) * (N + 1))
    for cy in range(N):
        for cx in range(N):
            cell = cy * N + cx
            grid[idx(cx, cy)] = mesh.node(0, cell, 0)
            grid[idx(cx + 1, cy)] = mesh.node(0, cell, 1)
            grid[idx(cx + 1, cy + 1)] = mesh.node(0, cell, 2)
            grid[idx(cx, cy + 1)] = mesh.node(0, cell, 3)
    fes = pyrucast.FiniteElementSpace(mesh)

    left = [grid[idx(0, j)] for j in range(N + 1)]
    bottom = [grid[idx(i, 0)] for i in range(N + 1)]
    model = pyrucast.model.elasticity(fes, "plane_stress")
    model = model | _clamp(model, left, "u_x")
    model = model | _clamp(model, bottom, "u_y")

    # Traction S on the right edge → consistent nodal loads (the flux op).
    right = pyrucast.Mesh(c, "SEG2")
    for j in range(N):
        right.unit().add_cell([grid[idx(N, j)], grid[idx(N, j + 1)]])
    right_fes = pyrucast.FiniteElementSpace(right)
    model = model | pyrucast.model.flux(right_fes, model, "f_x")
    materials = pyrucast.element_field.material_field(
        model, [("E", E), ("nu", NU), ("phi_f_x", S)]
    )
    rhs = pyrucast.node_field.external_forces(model, materials)

    solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), rhs)

    print(f"{'x':>5} {'y':>5} {'u_x':>12} {'u_y':>12}")
    tol = 1e-10
    for j in range(N + 1):
        for i in range(N + 1):
            x, y = i * h, j * h
            ux = solution.value(grid[idx(i, j)], "u_x")
            uy = solution.value(grid[idx(i, j)], "u_y")
            print(f"{x:5.2f} {y:5.2f} {ux:12.6e} {uy:12.6e}")
            assert abs(ux - S / E * x) < tol
            assert abs(uy + NU * S / E * y) < tol
    print("\nOK: uniaxial field matching u_x=(S/E)x, u_y=-(νS/E)y.")


if __name__ == "__main__":
    main()

Compléments

Thermomécanique non couplée

Première brique de thermomécanique : une température imposée ΔT engendre une déformation thermique de libre dilatation ε_th = α·(T − T_ref), d’où des contraintes mécaniques — sans rétroaction de la mécanique sur le thermique. En petites déformations, la rigidité K reste l’élastique ; le terme thermique n’agit que sur le second membre et sur la contrainte réelle :

\[ \sigma = D : (\varepsilon(u) - \varepsilon_{th}), \qquad f_{th} = \int_\Omega B^\top D\, \varepsilon_{th}\, d\Omega. \]

Aucune physique nouvelle : on compose les briques existantes. alpha est fourni au champ matériau (composante facultative) ; la température, portée aux points de Gauss par interp_to_gauss, alimente thermal_strain (EPTH) ; la charge thermique sort de integrate_behavior + internal_forces (BSIG) ; enfin la contrainte réelle se relit sur deformation(u) − ε_th.

Deux régimes sur une barre chauffée valident les fermetures analytiques : bord en x encastré aux deux bouts ⇒ σ_xx = −E·α·ΔT ; appuis simples ⇒ dilatation libre u = α·ΔT·(x, y) sans contrainte. Code = test tests/thermoelastic_bar.rs :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::field::Field;
use pyrucast::containers::finite_element_space::FiniteElementSpace;
use pyrucast::containers::mesh::{Mesh, SubMesh};
use pyrucast::containers::model::Model;
use pyrucast::containers::node_field::{NodeField, SubNodeField};
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::element_field::{deformation, interp_to_gauss, thermal_strain};
use pyrucast::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::node_field::internal_forces;
use pyrucast::ops::solver::lu::solve;
use pyrucast::Result;

#[test]
fn thermoelastic_constrained_bar_stress() -> Result<()> {
    const E: f64 = 210_000.0;
    const NU: f64 = 0.3;
    const ALPHA: f64 = 1e-5;
    const T_REF: f64 = 20.0;
    const DT: f64 = 100.0;
    const NX: usize = 4;
    const NY: usize = 2;
    const L: f64 = 4.0;
    const H: f64 = 1.0;
    let (hx, hy) = (L / NX as f64, H / NY as f64);

    // ── QUA4 mesh on [0,L]×[0,H] ───────────────────────────────────────────
    let coords = Handle::new(Coords::new(2)?);
    let idx = |i: usize, j: usize| j * (NX + 1) + i;
    let mut grid: Vec<Node> = Vec::new();
    for j in 0..=NY {
        for i in 0..=NX {
            grid.push(Node::create_in(
                coords.clone(),
                &[i as f64 * hx, j as f64 * hy],
            )?);
        }
    }
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::QUA4));
    for j in 0..NY {
        for i in 0..NX {
            mesh.add_cell(&[
                grid[idx(i, j)].id(),
                grid[idx(i + 1, j)].id(),
                grid[idx(i + 1, j + 1)].id(),
                grid[idx(i, j + 1)].id(),
            ])?;
        }
    }
    let fes = FiniteElementSpace::lagrange1(&mesh)?;

    // ── Modèle : élasticité + deux bords en x encastrés + appui u_y en bas ──
    let clamp = |target: &Model, nodes: &[Node], var: &str| -> Result<Model> {
        let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(nodes)?);
        let multiplier = mesh::barycenter(&imposed)?;
        model::dirichlet(target, var, &imposed, &multiplier, Default::default())
    };
    let left: Vec<Node> = (0..=NY).map(|j| grid[idx(0, j)].clone()).collect();
    let right: Vec<Node> = (0..=NY).map(|j| grid[idx(NX, j)].clone()).collect();
    let bottom: Vec<Node> = (0..=NX).map(|i| grid[idx(i, 0)].clone()).collect();
    let mut model = model::elasticity(&fes, Kinematics::PlaneStress)?;
    model = model.union(&clamp(&model, &left, "u_x")?)?;
    model = model.union(&clamp(&model, &right, "u_x")?)?;
    model = model.union(&clamp(&model, &bottom, "u_y")?)?;

    // `alpha` supplied through the material field — an optional elastic component.
    let materials = pyrucast::ops::element_field::material_field(
        &model,
        &[("E", E), ("nu", NU), ("alpha", ALPHA)],
    )?;

    // ── Imposed temperature T = T_ref + ΔT everywhere, carried at the Gauss points
    let support = Handle::new(SubMesh::poi1_from_nodes(&grid)?);
    let mut t_nodal = SubNodeField::from_poi1(&support, vec!["T".into()])?;
    for n in &grid {
        t_nodal.set_value(n.id(), "T", T_REF + DT)?;
    }
    let t_elem = interp_to_gauss(&NodeField::from_sub(t_nodal), &fes)?;

    // ── Charge thermique f_th = ∫ Bᵀ D ε_th (BSIG de σ_th = D:ε_th) ─────────
    let eps_th = thermal_strain(&t_elem, &materials, &fes, T_REF)?;
    let sig_th =
        pyrucast::ops::element_field::behavior::integrate(&model, &eps_th, None, &materials, None)?;
    let f_th = internal_forces(&model, &sig_th, &NodeField::empty(), &materials)?;

    // ── Assemblage + résolution ────────────────────────────────────────────
    let solution = solve(
        &pyrucast::ops::matrix::stiffness(&model, &materials)?,
        &f_th,
    )?;

    // ── Déplacement propre (u_x, u_y) puis σ = D:(ε(u) − ε_th) ─────────────
    let disp_support = Handle::new(SubMesh::poi1_from_nodes(&grid)?);
    let mut disp = SubNodeField::from_poi1(&disp_support, vec!["u_x".into(), "u_y".into()])?;
    for n in &grid {
        disp.set_value(n.id(), "u_x", solution.value(n.id(), "u_x")?)?;
        disp.set_value(n.id(), "u_y", solution.value(n.id(), "u_y")?)?;
    }
    let eps = deformation(&NodeField::from_sub(disp), &fes)?;
    let eps_mech = eps.merge_field(&eps_th, |a, b| a - b)?;
    let sigma = pyrucast::ops::element_field::behavior::integrate(
        &model, &eps_mech, None, &materials, None,
    )?;

    // ── Vérification : σ_xx = −E·α·ΔT, σ_yy = 0 ────────────────────────────
    let expected = -E * ALPHA * DT;
    let tol = 1e-6 * expected.abs();
    let sub = sigma.get(0)?.read();
    for cell in 0..sub.cell_count() {
        for g in 0..sub.gauss_count() {
            assert!((sub.value(cell, g, "sigma_xx")? - expected).abs() < tol);
            assert!(sub.value(cell, g, "sigma_yy")?.abs() < tol);
        }
    }
    Ok(())
}
"""Thermomécanique non couplée — barre chauffée (contraintes planes).

Physique
--------
An imposed temperature ΔT generates a free thermal strain
`ε_th = α·(T − T_ref)`. En petites déformations la rigidité reste élastique ;
the thermal term acts only on the right-hand side (equivalent thermal load
`f_th = ∫ Bᵀ D ε_th`) and on the real stress
`σ = D:(ε(u) − ε_th)`. Uncoupled: the mechanics does not feed back on the thermics.

Bricks composed by hand (no "all-in-one" operator):
`interp_to_gauss` (T nodale → points de Gauss), `thermal_strain` (EPTH),
`integrate_behavior` (σ = D:ε), `internal_forces` (BSIG), `solve`, puis
`deformation` and a field subtraction for ε_mech = ε(u) − ε_th.

`alpha` travels through the material field (`material_field`) as an
**optional** component of the elasticity, beside `E`/`nu`.

Two regimes on the same bar
------------------------------
- **bloquée** (deux bords en x encastrés) : `σ_xx = −E·α·ΔT`, `σ_yy = 0` ;
- **libre** (appuis simples) : dilatation `u = α·ΔT·(x, y)`, `σ ≈ 0`.

Lancement ::

    maturin develop --features extension-module
    python examples/thermoelastique_barre.py
"""

import pyrucast

E, NU, ALPHA = 210_000.0, 0.3, 1e-5
T_REF, DT = 20.0, 100.0
NX, NY, L, H = 4, 2, 4.0, 1.0


def _clamp(target, nodes, var):
    imposed = pyrucast.mesh.poi1_from_nodes(nodes)
    multiplier = pyrucast.mesh.barycenter(imposed)
    return pyrucast.model.dirichlet(target, var, imposed, multiplier)


def _bar():
    """An NX×NY grid of QUA4 on [0,L]×[0,H]. Returns (coords, grid, fes, idx)."""
    c = pyrucast.Coords(2)
    hx, hy = L / NX, H / NY

    def idx(i, j):
        return j * (NX + 1) + i

    grid = [c.add_node([i * hx, j * hy]) for j in range(NY + 1) for i in range(NX + 1)]
    mesh = pyrucast.Mesh(c, "QUA4")
    for j in range(NY):
        for i in range(NX):
            mesh.unit().add_cell(
                [
                    grid[idx(i, j)],
                    grid[idx(i + 1, j)],
                    grid[idx(i + 1, j + 1)],
                    grid[idx(i, j + 1)],
                ]
            )
    return c, grid, pyrucast.FiniteElementSpace(mesh), idx


def _uniform_temperature(c, grid, fes, value):
    """A temperature field 'T' = value everywhere, carried at the Gauss points."""
    t_mesh = pyrucast.Mesh(c, "POI1")
    for node in grid:
        t_mesh.unit().add_cell([node])
    t_nodal = pyrucast.NodeField(t_mesh, ["T"])
    for node in grid:
        t_nodal[0].set_value(node, "T", value)
    return pyrucast.element_field.interp_to_gauss(t_nodal, fes)


def _displacement(solution, c, grid):
    """Extracts a clean (u_x, u_y) field (without the Lagrange multipliers)."""
    u_mesh = pyrucast.Mesh(c, "POI1")
    for node in grid:
        u_mesh.unit().add_cell([node])
    u = pyrucast.NodeField(u_mesh, ["u_x", "u_y"])
    for node in grid:
        u[0].set_value(node, "u_x", solution.value(node, "u_x"))
        u[0].set_value(node, "u_y", solution.value(node, "u_y"))
    return u


def _solve_thermal(model, materials, fes, c, grid):
    """ε_th → charge thermique → u → σ = D:(ε(u) − ε_th)."""
    eps_th = pyrucast.element_field.thermal_strain(
        _uniform_temperature(c, grid, fes, T_REF + DT), materials, fes, T_REF
    )
    sig_th = pyrucast.element_field.integrate_behavior(model, eps_th, materials)
    # A **load**, not a residual: the divergence of the prescribed tensor, hence
    # the geometric operator. They are then renamed into dual rows — that is
    # where, and only where, these numbers become forces.
    f_th = (
        pyrucast.node_field.divergence(sig_th, "sigma")
        .rename_component("div_sigma_x", "f_x")
        .rename_component("div_sigma_y", "f_y")
    )
    solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), f_th)
    u = _displacement(solution, c, grid)
    sigma = pyrucast.element_field.integrate_behavior(
        model, pyrucast.element_field.deformation(u, fes) - eps_th, materials
    )
    return u, sigma


def main() -> None:
    # ── Régime bloqué : σ_xx = −E·α·ΔT ──────────────────────────────────────
    c, grid, fes, idx = _bar()
    left = [grid[idx(0, j)] for j in range(NY + 1)]
    right = [grid[idx(NX, j)] for j in range(NY + 1)]
    bottom = [grid[idx(i, 0)] for i in range(NX + 1)]

    model = pyrucast.model.elasticity(fes, "plane_stress")
    model = (
        model
        | _clamp(model, left, "u_x")
        | _clamp(model, right, "u_x")
        | _clamp(model, bottom, "u_y")
    )
    materials = pyrucast.element_field.material_field(
        model, [("E", E), ("nu", NU), ("alpha", ALPHA)]
    )

    _u, sigma = _solve_thermal(model, materials, fes, c, grid)
    expected = -E * ALPHA * DT
    sub = sigma[0]
    sxx = sub.value(0, 0, "sigma_xx")
    print(f"Bloquée : σ_xx = {sxx:12.4f}  (attendu {expected:.4f} = −E·α·ΔT)")
    assert abs(sxx - expected) < 1e-6 * abs(expected)

    # ── Régime libre : dilatation u = α·ΔT·(x, y), σ ≈ 0 ────────────────────
    c, grid, fes, idx = _bar()
    left = [grid[idx(0, j)] for j in range(NY + 1)]
    bottom = [grid[idx(i, 0)] for i in range(NX + 1)]

    model = pyrucast.model.elasticity(fes, "plane_stress")
    model = model | _clamp(model, left, "u_x") | _clamp(model, bottom, "u_y")
    materials = pyrucast.element_field.material_field(
        model, [("E", E), ("nu", NU), ("alpha", ALPHA)]
    )

    u, sigma = _solve_thermal(model, materials, fes, c, grid)
    tip = grid[idx(NX, NY)]
    ux, uy = u.value(tip, "u_x"), u.value(tip, "u_y")
    print(
        f"Libre   : u(coin) = ({ux:.6e}, {uy:.6e})  (attendu ({ALPHA * DT * L:.6e}, {ALPHA * DT * H:.6e}))"
    )
    assert abs(ux - ALPHA * DT * L) < 1e-9 and abs(uy - ALPHA * DT * H) < 1e-9
    assert abs(sigma[0].value(0, 0, "sigma_xx")) < 1e-6

    print("\nOK: blocked bar → σ_xx = −E·α·ΔT; free bar → expansion without stress.")


if __name__ == "__main__":
    main()