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

Contact

Jusqu’ici, la masse était un effort posé sur l’alésage. Dans ce dernier calcul, elle devient une pièce : un appui posé dans l’alésage, qui porte le poids sur son axe. L’appui est un secteur de 90° du même rayon que le trou, mille fois plus raide que la chape, et il ne peut que monter ou descendre. Entre les deux pièces, un contact : l’appui ne traverse pas la paroi de l’alésage, et il ne peut que la pousser, jamais la tirer. Où il touche, et avec quel effort, c’est le calcul qui le trouve.

Le script complet est tutoriel/contact.py ; tous les extraits ci-dessous en sont issus directement, dans l’ordre du fichier.

L’équation résolue

Deux pièces élastiques, la chape et l’appui, chacune avec sa rigidité. La chape est encastrée ; l’appui a ses déplacements \( u_x \) et \( u_y \) bloqués partout. Le poids \( mg \) est appliqué sur son axe.

Le contact s’écrit nœud par nœud. Chaque nœud \( s \) de la face de l’appui est apparié au point le plus proche de la moitié basse de l’alésage. On y lit la normale \( n \), tournée vers l’appui, le jeu de départ \( g_0 \), et les poids \( N_i \) des nœuds de la face de l’alésage en ce point. Le jeu pendant le calcul ne doit jamais devenir négatif :

\[ g = g_0 + n \cdot u(s) - \sum_i N_i\, n \cdot u(\text{alésage}_i) \ \ge\ 0 \]

Chaque nœud de l’appui a son multiplicateur \( \lambda \), comme un blocage. C’est l’effort de contact, qui ne peut que pousser : \( \lambda \le 0 \). L’un des deux est toujours nul : un nœud qui touche a un jeu nul, un nœud décollé n’a pas d’effort.

\[ g \ge 0, \qquad \lambda \le 0, \qquad \lambda\, g = 0 \]

pyrucast résout cette alternative par statuts. Il suppose quels nœuds touchent, et impose \( g = 0 \) à ceux-là seulement, comme des blocages. Il résout, puis corrige : un nœud supposé touchant dont l’effort tirerait est libéré, un nœud supposé libre qui traverserait est ajouté. Il recommence jusqu’à ce que tout soit cohérent. Ce tri se fait à chaque résolution du pas à pas de la page Mécanique non linéaire, le poids monté en 20 pas.

L’appariement est fait une fois, sur la géométrie de départ : les déplacements restent petits devant l’alésage, et l’appui ne glisse pas d’une face à l’autre.

Les données du calcul

E, NU = 200e9, 0.3  # Pa, — : la chape / the lug
E_APPUI = 1000.0 * E  # Pa : l'appui, quasi indéformable / nearly rigid support
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N
T_FINAL, N_PAS = 1.0, 20  # temps fictif / fictitious time
AMPLIFICATION = 50.0
TOL = 1e-9  # m

E_APPUI, mille fois le module de la chape, fait de l’appui une pièce presque indéformable. Le temps fictif va de 0 à 1, en 20 pas.

La chape

    # FR — La chape, ses régions : l'encastrement et la moitié basse de
    #      l'alésage, comme à la page « Élasticité ».
    # EN — The lug, its regions: the clamp and the lower half of the bore, as
    #      on the "Élasticité" page.
    _, volume = structured_mesh(plot=False)
    coords = volume.coords()
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))
    encastrement = pc.mesh.points_on_plane(volume, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    face_gauche = pc.mesh.elements_on(peau, encastrement, strict=True)
    bas_axe = [LENGTH, -THICKNESS, HEIGHT / 2.0]
    haut_axe = [LENGTH, 2.0 * THICKNESS, HEIGHT / 2.0]
    noeuds_alesage = pc.mesh.points_on_cylinder(peau, bas_axe, haut_axe, HOLE_RADIUS)
    alesage = pc.mesh.elements_on(peau, noeuds_alesage, strict=True)
    z_alesage = pc.node_field.positions(alesage, ["Z"])
    noeuds_bas = pc.mesh.select(z_alesage, le=HEIGHT / 2.0 + TOL)
    demi_alesage = pc.mesh.consolidate(
        pc.mesh.elements_on(alesage, noeuds_bas, strict=True)
    )

Les régions de la page Élasticité : l’encastrement et la moitié basse de l’alésage, sur le maillage HEX20 importé.

L’appui

    # FR — L'appui : un rayon, du centre du trou vers le bas à gauche, à 45°
    #      de la verticale, en 2 segments ; tourné de 90° autour de l'axe du
    #      trou en 7 secteurs, il balaie le quart bas du disque. Le nœud du
    #      centre est sur l'axe : les mailles qui le touchent sont des
    #      triangles. Puis l'épaisseur, en 2 couches.
    # EN — The support: a radius, from the hole's centre down to the left,
    #      45° off the vertical, in 2 segments; turned by 90° about the
    #      hole's axis in 7 sectors, it sweeps the disc's lower quarter. The
    #      centre node is on the axis: the cells touching it are triangles.
    #      Then the thickness, in 2 layers.
    centre = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0])
    s = HOLE_RADIUS / math.sqrt(2.0)
    depart = coords.add_node([LENGTH - s, 0.0, HEIGHT / 2.0 - s])
    rayon = pc.mesh.line(centre, depart, 2)
    secteur = pc.mesh.revolve(
        rayon, math.pi / 2.0, 7, [LENGTH, 0.0, HEIGHT / 2.0], [0.0, -1.0, 0.0]
    )
    appui = pc.mesh.extrude(secteur, [0.0, THICKNESS, 0.0], 2)
    noeuds_appui = pc.mesh.consolidate(pc.mesh.to_poi1(appui))

    # FR — Sur l'appui : son axe, où s'applique le poids, et sa face
    #      cylindrique, qui touche l'alésage.
    # EN — On the support: its axis, where the weight is applied, and its
    #      cylindrical face, which touches the bore.
    axe = pc.mesh.consolidate(
        pc.mesh.points_on_line(
            noeuds_appui, [LENGTH, 0.0, HEIGHT / 2.0], [LENGTH, 1.0, HEIGHT / 2.0]
        )
    )
    face_appui = pc.mesh.consolidate(
        pc.mesh.points_on_cylinder(noeuds_appui, bas_axe, haut_axe, HOLE_RADIUS)
    )

    face_gauche.unit().face_color = ROUGE
    demi_alesage.unit().face_color = ORANGE
    for zone in appui:
        zone.face_color = VERT
    show(
        peau | face_gauche | demi_alesage | appui,
        "Encastrement, moitié basse de l'alésage, appui",
        "contact-regions.svg",
        wireframe=True,
    )

L’appui se construit comme une pièce de la page Maillage, par balayage :

  1. un rayon du centre du trou vers le bas à gauche, à 45° de la verticale, en 2 segments ;
  2. tourné de 90° autour de l’axe du trou en 7 secteurs, il balaie le quart bas du disque. Le nœud du centre est sur l’axe de rotation : il reste unique, et les mailles qui le touchent sont des triangles au lieu de quadrangles ;
  3. extrudé dans l’épaisseur, en 2 couches.

Le script affiche 28 mailles, 14 HEX8 et 14 PENTA6, et 51 nœuds :

  • 3 sur l’axe, où s’applique le poids ;
  • 24 sur la face cylindrique, celle qui touche l’alésage.

L’appui est maillé en éléments linéaires, la chape en quadratiques : les deux pièces n’ont aucun nœud en commun, et c’est le contact qui les relie.

Le modèle

    # FR — Deux élasticités, une par pièce, réunies par `|`. La chape est
    #      encastrée ; l'appui ne peut ni avancer ni s'écarter (u_x, u_y
    #      bloqués), seulement monter ou descendre.
    # EN — Two elasticities, one per part, joined by `|`. The lug is clamped;
    #      the support can neither move forward nor sideways (u_x, u_y
    #      blocked), only up or down.
    chape = pc.model.elasticity(pc.FiniteElementSpace(volume), "full_3d")
    piece_appui = pc.model.elasticity(pc.FiniteElementSpace(appui), "full_3d")
    solides = chape | piece_appui
    modele = (
        solides
        | pc.model.dirichlet(encastrement, chape, "u_x")
        | pc.model.dirichlet(encastrement, chape, "u_y")
        | pc.model.dirichlet(encastrement, chape, "u_z")
        | pc.model.dirichlet(noeuds_appui, piece_appui, "u_x")
        | pc.model.dirichlet(noeuds_appui, piece_appui, "u_y")
    )

    # FR — Le contact : les nœuds de la face de l'appui (esclaves) ne
    #      traversent pas la moitié basse de l'alésage (maître), ses QUA8
    #      telles quelles, courbes.
    # EN — The contact: the nodes of the support's face (slaves) do not cross
    #      the lower half of the bore (master), its QUA8 as they are, curved.
    contact = pc.model.contact(face_appui, demi_alesage, solides, ["u_x", "u_y", "u_z"])
    modele = modele | contact

    # FR — Un matériau par pièce : chaque champ porte sur son modèle.
    # EN — One material per part: each field covers its own model.
    materiaux = pc.element_field.material_field(
        chape, [("E", E), ("nu", NU)]
    ) | pc.element_field.material_field(piece_appui, [("E", E_APPUI), ("nu", NU)])

Deux élasticités, une par pièce, réunies par | : c’est la cible du contact, qui relie les déplacements des deux. pc.model.contact(esclave, maître, cible, variables) prend :

  • les nœuds esclaves, ceux de la face de l’appui ;
  • la surface maître, les faces QUA8 de la moitié basse de l’alésage ;
  • les composantes du déplacement que la normale relie, une par direction.

Chaque pièce a son matériau, et la réunion | des deux champs en fait un seul.

La surface maître reste courbe. Elle se donne telle que le maillage la porte : des faces QUA8, avec leurs nœuds milieux, qui suivent le cercle de l’alésage. Découpée en triangles plats, elle passerait en corde à l’intérieur du trou, et l’appui démarrerait enfoncé dans la chape. La page Contact du chapitre des contraintes l’explique.

Le chargement et le calcul

    # FR — Le poids, partagé entre les nœuds de l'axe de l'appui, et monté de
    #      0 à 1 sur le temps fictif, en 20 pas.
    # EN — The weight, shared between the nodes of the support's axis, and
    #      ramped from 0 to 1 over the fictitious time, in 20 steps.
    poids = pc.NodeField(axe, ["f_z"])
    for i in range(axe.cell_count):
        poids[0].set_value(axe.node(0, i, 0), "f_z", -POIDS / axe.cell_count)
    instants = []
    for i in range(N_PAS + 1):
        instants.append(i * T_FINAL / N_PAS)
    donnees = {
        "model": modele,
        "times": instants,
        "materials": materiaux,
        "loads": pc.Evolution([(0.0, poids * 0.0), (T_FINAL, poids)]),
    }
    pc.stepping.step_by_step(donnees)

Le poids est partagé à parts égales entre les 3 nœuds de l’axe de l’appui, et il monte de 0 à 1 fois lui-même sur le temps fictif. step_by_step reçoit le même genre de dictionnaire qu’à la page Plasticité, sans température. Le script affiche 2 itérations à chaque pas, 40 en tout : les deux matériaux restent élastiques, et peu de corrections suffisent à chaque pas.

L’enfoncement

    # FR — L'enfoncement de l'appui, pas après pas : u_z d'un nœud de son
    #      axe, en mm.
    # EN — The support's sinking, step after step: u_z of a node on its
    #      axis, in mm.
    noeud_axe = axe.node(0, 0, 0)
    enfoncement = []
    for resultat in donnees["results"]:
        enfoncement.append(
            (
                resultat["time"],
                resultat["displacement"].value(noeud_axe, "u_z") * 1000.0,
            )
        )
    pc.SubEvolution(enfoncement).plot(
        x_label="temps fictif",
        y_label="u_z de l'appui (mm)",
        title="Enfoncement de l'appui",
        save=os.path.join(OUT, "contact-enfoncement.svg") if OUT else None,
    )

L’appui descend de 0,0349 mm au premier pas et de 0,7396 mm au dernier. La courbe est une droite. L’appui et l’alésage ont le même rayon : au départ, ils se touchent sans jeu. Doubler le poids double alors tous les déplacements, et l’enfoncement reste proportionnel au poids.

Où l’appui porte

    # FR — Les réactions du dernier pas. Sur les nœuds de l'alésage, que rien
    #      d'autre ne tient, ce sont les efforts du contact sur la chape ; on
    #      les sépare de part et d'autre du centre du trou, les nœuds juste
    #      dessous comptés du côté de l'encastrement.
    # EN — The last step's reactions. On the bore's nodes, which nothing else
    #      holds, they are the contact's forces on the lug; they are split on
    #      either side of the hole's centre, the nodes right below it counted
    #      on the clamp's side.
    dernier = donnees["results"][-1]
    reactions = dernier["reactions"]
    sur_l_encastrement = pc.node_field.restrict(reactions, encastrement)
    sur_l_alesage = pc.node_field.restrict(reactions, demi_alesage)
    x_alesage = pc.node_field.positions(demi_alesage, ["X"])
    cote_encastrement = pc.node_field.restrict(
        reactions, pc.mesh.select(x_alesage, le=LENGTH + TOL)
    )
    cote_bout_libre = pc.node_field.restrict(
        reactions, pc.mesh.select(x_alesage, gt=LENGTH + TOL)
    )

Les réactions du pas à pas sont les efforts que les liaisons exercent sur les pièces. Les nœuds de l’alésage ne sont tenus que par le contact : leurs réactions sont les efforts de contact sur la chape. Le script affiche :

\( f_x \)\( f_z \)
contact sur la chape, en tout−13 829,6 N−24 525,0 N
côté encastrement−22 309,9 N
côté bout libre−2 215,1 N
réaction de l’encastrement13 829,6 N24 525,0 N

Le contact transmet exactement le poids, et l’encastrement le rend. Mais l’appui ne porte pas également des deux côtés : neuf dixièmes de l’effort passent par la paroi côté encastrement. Cette paroi est adossée à toute la pièce ; celle du côté du bout libre n’a derrière elle que l’anneau, plus souple. L’appui ne peut que descendre, et c’est la paroi la plus raide qui le porte.

Un contact sans frottement pousse selon la normale. La paroi côté encastrement est inclinée : son effort a une part horizontale, qui pousse la chape vers l’encastrement, 13 829,6 N. L’encastrement la retient par une réaction égale et opposée, et les blocages horizontaux de l’appui tiennent l’appui. Un effort horizontal de plus de la moitié du poids, pour une charge toute verticale : c’est le contact qui le crée.

La déformée

    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(dernier["displacement"] * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        (volume | appui).plot(
            view=VUE,
            title="Contact — déformée ×50 au dernier pas, u_z (mm)",
            field=dernier["displacement"] * 1000.0,
            component="u_z",
            cmap="viridis",
            save=os.path.join(OUT, "contact-deformee.svg") if OUT else None,
        )

Les deux pièces, dans la configuration déformée, colorées par \( u_z \). L’appui, presque indéformable, descend d’un bloc ; la chape fléchit comme sous la force surfacique de la page Élasticité.

Script complet

"""Tutoriel débutant — 9.8.2 Contact. / Beginner tutorial — 9.8.2 Contact.

FR — La masse suspendue des pages précédentes devient un **appui** posé dans
l'alésage : un secteur de 90° du même rayon que le trou, mille fois plus
raide que la chape, qui ne peut que monter ou descendre. Le poids est appliqué
sur l'axe de l'appui, en 20 pas. Entre l'appui et la moitié basse de
l'alésage, un **contact** : les nœuds de l'appui ne traversent pas la paroi,
et le contact ne fait que pousser. Le détail pas à pas est dans le livre,
page « Contact ».

EN — The previous pages' hanging mass becomes a **support** laid in the
bore: a 90° sector of the hole's radius, a thousand times stiffer than the
lug, free only to move up or down. The weight is applied on the support's
axis, in 20 steps. Between the support and the lower half of the bore, a
**contact**: the support's nodes do not cross the wall, and the contact only
pushes. The step-by-step walkthrough lives in the book's "Contact" page.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/contact.py

    # Figures du livre / book figures (book/src/tutoriel/img/) :
    # PYRUCAST_TUTORIEL_IMG_DIR=book/src/tutoriel/img python tutoriel/contact.py
"""

import math
import os

import pyrucast as pc
from maillage import HEIGHT, HOLE_RADIUS, LENGTH, OUT, THICKNESS, show, structured_mesh

# FR — La vue des figures de tutoriel/maillage.py : azimut, élévation, échelle.
# EN — The view of tutoriel/maillage.py's figures: azimuth, elevation, scale.
VUE = (-45, 25, 1.0)

ROUGE = (255, 0, 0)  # encastrement / clamp
ORANGE = (255, 140, 0)  # moitié basse de l'alésage / lower half of the bore
VERT = (0, 170, 0)  # l'appui / the support

# ── Données physiques / Physical data ──────────────────────────────────────
E, NU = 200e9, 0.3  # Pa, — : la chape / the lug
E_APPUI = 1000.0 * E  # Pa : l'appui, quasi indéformable / nearly rigid support
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N
T_FINAL, N_PAS = 1.0, 20  # temps fictif / fictitious time
AMPLIFICATION = 50.0
TOL = 1e-9  # m


def main() -> None:
    # FR — La chape, ses régions : l'encastrement et la moitié basse de
    #      l'alésage, comme à la page « Élasticité ».
    # EN — The lug, its regions: the clamp and the lower half of the bore, as
    #      on the "Élasticité" page.
    _, volume = structured_mesh(plot=False)
    coords = volume.coords()
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))
    encastrement = pc.mesh.points_on_plane(volume, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    face_gauche = pc.mesh.elements_on(peau, encastrement, strict=True)
    bas_axe = [LENGTH, -THICKNESS, HEIGHT / 2.0]
    haut_axe = [LENGTH, 2.0 * THICKNESS, HEIGHT / 2.0]
    noeuds_alesage = pc.mesh.points_on_cylinder(peau, bas_axe, haut_axe, HOLE_RADIUS)
    alesage = pc.mesh.elements_on(peau, noeuds_alesage, strict=True)
    z_alesage = pc.node_field.positions(alesage, ["Z"])
    noeuds_bas = pc.mesh.select(z_alesage, le=HEIGHT / 2.0 + TOL)
    demi_alesage = pc.mesh.consolidate(
        pc.mesh.elements_on(alesage, noeuds_bas, strict=True)
    )

    # FR — L'appui : un rayon, du centre du trou vers le bas à gauche, à 45°
    #      de la verticale, en 2 segments ; tourné de 90° autour de l'axe du
    #      trou en 7 secteurs, il balaie le quart bas du disque. Le nœud du
    #      centre est sur l'axe : les mailles qui le touchent sont des
    #      triangles. Puis l'épaisseur, en 2 couches.
    # EN — The support: a radius, from the hole's centre down to the left,
    #      45° off the vertical, in 2 segments; turned by 90° about the
    #      hole's axis in 7 sectors, it sweeps the disc's lower quarter. The
    #      centre node is on the axis: the cells touching it are triangles.
    #      Then the thickness, in 2 layers.
    centre = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0])
    s = HOLE_RADIUS / math.sqrt(2.0)
    depart = coords.add_node([LENGTH - s, 0.0, HEIGHT / 2.0 - s])
    rayon = pc.mesh.line(centre, depart, 2)
    secteur = pc.mesh.revolve(
        rayon, math.pi / 2.0, 7, [LENGTH, 0.0, HEIGHT / 2.0], [0.0, -1.0, 0.0]
    )
    appui = pc.mesh.extrude(secteur, [0.0, THICKNESS, 0.0], 2)
    noeuds_appui = pc.mesh.consolidate(pc.mesh.to_poi1(appui))

    # FR — Sur l'appui : son axe, où s'applique le poids, et sa face
    #      cylindrique, qui touche l'alésage.
    # EN — On the support: its axis, where the weight is applied, and its
    #      cylindrical face, which touches the bore.
    axe = pc.mesh.consolidate(
        pc.mesh.points_on_line(
            noeuds_appui, [LENGTH, 0.0, HEIGHT / 2.0], [LENGTH, 1.0, HEIGHT / 2.0]
        )
    )
    face_appui = pc.mesh.consolidate(
        pc.mesh.points_on_cylinder(noeuds_appui, bas_axe, haut_axe, HOLE_RADIUS)
    )

    face_gauche.unit().face_color = ROUGE
    demi_alesage.unit().face_color = ORANGE
    for zone in appui:
        zone.face_color = VERT
    show(
        peau | face_gauche | demi_alesage | appui,
        "Encastrement, moitié basse de l'alésage, appui",
        "contact-regions.svg",
        wireframe=True,
    )

    # FR — Deux élasticités, une par pièce, réunies par `|`. La chape est
    #      encastrée ; l'appui ne peut ni avancer ni s'écarter (u_x, u_y
    #      bloqués), seulement monter ou descendre.
    # EN — Two elasticities, one per part, joined by `|`. The lug is clamped;
    #      the support can neither move forward nor sideways (u_x, u_y
    #      blocked), only up or down.
    chape = pc.model.elasticity(pc.FiniteElementSpace(volume), "full_3d")
    piece_appui = pc.model.elasticity(pc.FiniteElementSpace(appui), "full_3d")
    solides = chape | piece_appui
    modele = (
        solides
        | pc.model.dirichlet(encastrement, chape, "u_x")
        | pc.model.dirichlet(encastrement, chape, "u_y")
        | pc.model.dirichlet(encastrement, chape, "u_z")
        | pc.model.dirichlet(noeuds_appui, piece_appui, "u_x")
        | pc.model.dirichlet(noeuds_appui, piece_appui, "u_y")
    )

    # FR — Le contact : les nœuds de la face de l'appui (esclaves) ne
    #      traversent pas la moitié basse de l'alésage (maître), ses QUA8
    #      telles quelles, courbes.
    # EN — The contact: the nodes of the support's face (slaves) do not cross
    #      the lower half of the bore (master), its QUA8 as they are, curved.
    contact = pc.model.contact(face_appui, demi_alesage, solides, ["u_x", "u_y", "u_z"])
    modele = modele | contact

    # FR — Un matériau par pièce : chaque champ porte sur son modèle.
    # EN — One material per part: each field covers its own model.
    materiaux = pc.element_field.material_field(
        chape, [("E", E), ("nu", NU)]
    ) | pc.element_field.material_field(piece_appui, [("E", E_APPUI), ("nu", NU)])

    # FR — Le poids, partagé entre les nœuds de l'axe de l'appui, et monté de
    #      0 à 1 sur le temps fictif, en 20 pas.
    # EN — The weight, shared between the nodes of the support's axis, and
    #      ramped from 0 to 1 over the fictitious time, in 20 steps.
    poids = pc.NodeField(axe, ["f_z"])
    for i in range(axe.cell_count):
        poids[0].set_value(axe.node(0, i, 0), "f_z", -POIDS / axe.cell_count)
    instants = []
    for i in range(N_PAS + 1):
        instants.append(i * T_FINAL / N_PAS)
    donnees = {
        "model": modele,
        "times": instants,
        "materials": materiaux,
        "loads": pc.Evolution([(0.0, poids * 0.0), (T_FINAL, poids)]),
    }
    pc.stepping.step_by_step(donnees)

    # FR — L'enfoncement de l'appui, pas après pas : u_z d'un nœud de son
    #      axe, en mm.
    # EN — The support's sinking, step after step: u_z of a node on its
    #      axis, in mm.
    noeud_axe = axe.node(0, 0, 0)
    enfoncement = []
    for resultat in donnees["results"]:
        enfoncement.append(
            (
                resultat["time"],
                resultat["displacement"].value(noeud_axe, "u_z") * 1000.0,
            )
        )
    pc.SubEvolution(enfoncement).plot(
        x_label="temps fictif",
        y_label="u_z de l'appui (mm)",
        title="Enfoncement de l'appui",
        save=os.path.join(OUT, "contact-enfoncement.svg") if OUT else None,
    )

    # FR — Les réactions du dernier pas. Sur les nœuds de l'alésage, que rien
    #      d'autre ne tient, ce sont les efforts du contact sur la chape ; on
    #      les sépare de part et d'autre du centre du trou, les nœuds juste
    #      dessous comptés du côté de l'encastrement.
    # EN — The last step's reactions. On the bore's nodes, which nothing else
    #      holds, they are the contact's forces on the lug; they are split on
    #      either side of the hole's centre, the nodes right below it counted
    #      on the clamp's side.
    dernier = donnees["results"][-1]
    reactions = dernier["reactions"]
    sur_l_encastrement = pc.node_field.restrict(reactions, encastrement)
    sur_l_alesage = pc.node_field.restrict(reactions, demi_alesage)
    x_alesage = pc.node_field.positions(demi_alesage, ["X"])
    cote_encastrement = pc.node_field.restrict(
        reactions, pc.mesh.select(x_alesage, le=LENGTH + TOL)
    )
    cote_bout_libre = pc.node_field.restrict(
        reactions, pc.mesh.select(x_alesage, gt=LENGTH + TOL)
    )

    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(dernier["displacement"] * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        (volume | appui).plot(
            view=VUE,
            title="Contact — déformée ×50 au dernier pas, u_z (mm)",
            field=dernier["displacement"] * 1000.0,
            component="u_z",
            cmap="viridis",
            save=os.path.join(OUT, "contact-deformee.svg") if OUT else None,
        )

    iterations = []
    for resultat in donnees["results"]:
        iterations.append(resultat["iterations"])
    print(f"appui : {appui.element_types()} {[z.cell_count for z in appui]} mailles")
    print(
        f"appui : {noeuds_appui.cell_count} nœuds, {axe.cell_count} sur l'axe, "
        f"{face_appui.cell_count} sur la face"
    )
    print(f"itérations par pas {iterations[1:]}, {sum(iterations)} en tout")
    print(
        f"enfoncement de l'appui : {enfoncement[1][1]:.4f} mm au premier pas, "
        f"{enfoncement[-1][1]:.4f} mm au dernier"
    )
    print(
        f"contact sur la chape : f_x {sur_l_alesage.sum('f_x'):.1f} N, "
        f"f_z {sur_l_alesage.sum('f_z'):.1f} N"
    )
    print(
        f"  côté encastrement : f_z {cote_encastrement.sum('f_z'):.1f} N ; "
        f"côté bout libre : f_z {cote_bout_libre.sum('f_z'):.1f} N"
    )
    print(
        f"réactions de l'encastrement : f_x {sur_l_encastrement.sum('f_x'):.1f} N, "
        f"f_y {sur_l_encastrement.sum('f_y'):.1f} N, "
        f"f_z {sur_l_encastrement.sum('f_z'):.1f} N"
    )


if __name__ == "__main__":
    main()

Suite : Compléments.