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

Introduction

pyrucast est une librairie d’éléments finis dont le cœur est écrit en Rust et qui expose une API Python. Elle s’inspire des principes de Cast3M : un noyau d’objets typés, accompagné de fonctions opérant sur ces objets.

📖 Référence API Rust (rustdoc). Cette documentation couvre les principes, l’architecture et les exemples ; la référence par item (signatures, types, modules) est générée par rustdoc et publiée à côté de ce book : https://pyrucast.github.io/pyrucast/rust/.

Philosophie

  • Simplicité avant tout. Le code doit rester maintenable et éditable par un humain non expert ; on évite la sophistication gratuite.
  • Dépendances minimales. Tout ajout de dépendance externe (Rust ou Python) requiert un accord explicite.
  • Vérification continue. Chaque objet est livré avec des tests unitaires Rust, des doctests, des tests Python et un chapitre de cette documentation.

Modèle d’objets (arbre de dépendances)

Chaque structure ne dépend que des structures qui la précèdent. La plupart viennent par paire zone / agrégat (SubMesh/Mesh…) — c’est le motif Agrégat, avec sa composition par union |.

Coords ── Node                          (NodeId u32 stable ; Node = accesseur RAII)
   │
   └── Mesh  (agrège des SubMesh, + ElementType)   géométrie
          ├── NodeField  (agrège des SubNodeField, support POI1)   valeurs par nœud × composante
          └── FiniteElementSpace  (agrège des SubFiniteElementSpace)
                 │                  (+ Interpolation, QuadratureRule)
                 ├── ElementField  (agrège des SubElementField)   valeurs aux Gauss
                 └── Model  (agrège des SubModel : physiques + contraintes)
                        └── ops::matrix ──► Matrix   (matrice creuse, DOFs nommés)

Evolution  (agrège des SubEvolution)    scalaire, NodeField ou ElementField
                                        tabulé vs une variable

Model + ElementField (matériau) ──► stiffness ──► Matrix
Matrix + NodeField (second membre) ──► solve ──► NodeField (solution)

Deux traits transverses factorisent le comportement commun : Aggregate (accès len/[i], union — | en Python, union en Rust) et Field/SubField (composantes nommées, min/max, arithmétique), partagés entre NodeField et ElementField.

Résumé des rôles :

StructureRôle
CoordsRéférentiel de coordonnées de nœuds (plusieurs configurations)
NodeAccesseur utilisateur d’un nœud, avec protection GC automatique
SubMesh / MeshCellules d’un même ElementType / union de sous-maillages
SubNodeField / NodeFieldValeurs par nœud × composante (zone / agrégat)
SubFiniteElementSpace / FiniteElementSpaceFormulation EF (interpolation + quadrature) / union
SubElementField / ElementFieldValeurs par cellule × point de Gauss × composante
SubModel / ModelPhysique ou contrainte locale / problème complet
SubMatrix / MatrixMatrice creuse dont les lignes/colonnes sont des DOFs nommés (NodeId, inconnue), par ex. l’inconnue "UX" d’un nœud
SubEvolution / EvolutionValeur (scalaire ou champ) tabulée vs une variable, interpolée linéairement (zone / agrégat)

Premiers pas

Exemple minimal en Rust :

#[test]
fn un_maillage_minimal() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let a = Node::create_in(coords.clone(), &[0.0, 0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0, 0.0])?;

    let mesh = mesh::line(&a, &b, 10, ElementType::SEG2)?;
    println!("{}", mesh); // Mesh: 1 submesh(es) [#…], 10 cell(s) total
    Ok(())
}

En Python, il suffit d’installer le paquet :

pip install pyrucast

Exemple équivalent en Python :

import pyrucast

c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])

mesh = pyrucast.mesh.line(a, b, 10)
print(mesh)  # Mesh: 1 submesh(es) [#…], 10 cell(s) total

Installation et démarrage rapide

Cette page suffit pour utiliser pyrucast en Python : l’installer, vérifier qu’il fonctionne, écrire un premier script. Deux voies : la version précompilée, sans rien compiler, ou la compilation depuis les sources. Pour développer sur la librairie (tests Rust, doctests, génération de la documentation, features Cargo), voir Compilation et tests.

Rust pur ? pyrucast s’utilise aussi comme bibliothèque Rust sans Python : pyo3 y est une dépendance optionnelle, donc un build par défaut ne tire ni pyo3 ni libpython. Voir Usage en Rust pur.

Version précompilée

pip install pyrucast

et pour passer à la dernière version :

pip install --upgrade pyrucast

PyPI distribue pyrucast en wheels — des paquets précompilés, prêts à installer — pour trois plateformes :

SystèmeProcesseur
Linux, glibc ≥ 2.17 (manylinux2014)x86_64
Windows 64 bitsx86_64
macOS ≥ 10.12 (Intel), ≥ 11 (Apple Silicon)x86_64 et arm64 (universal2)

Seul prérequis : Python ≥ 3.11. Ni Rust ni compilateur. La wheel comprend la visualisation (plot), fenêtre interactive incluse.

Sur une autre plateforme (Linux ARM, musl, BSD…), pip ne trouve pas de wheel et compile lui-même les sources : il faut alors Rust (voir ci-dessous). Cette compilation automatique se fait sans la visualisation — voir Ce que porte chaque distribution. Pour l’avoir, compilez à la main, comme le décrit la partie Compilation ci-dessous.

Compilation

Pour suivre la version en développement, ou pour compiler avec la visualisation sur une plateforme sans wheel.

Prérequis

  • Git, pour cloner le dépôt.
  • Rust stable, installé via rustup.
  • Python ≥ 3.11.
  • Linux uniquement : la visualisation lie fontconfig et freetype, il faut leurs en-têtes — pkg-config libfontconfig1-dev libfreetype6-dev (Debian/Ubuntu) ou pkg-config fontconfig-devel freetype-devel (Fedora/RHEL). Windows et macOS passent par les polices du système.

Compiler et installer

On clone le dépôt, puis on compile à sa racine, dans un environnement virtuel : maturin develop refuse de s’en passer (« Couldn’t find a virtualenv or conda environment »).

Linux / macOS (bash)

git clone https://github.com/Pyrucast/pyrucast
cd pyrucast
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip maturin
maturin develop --release --features extension-module,viz-interactive

Windows (PowerShell)

git clone https://github.com/Pyrucast/pyrucast
cd pyrucast
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install --upgrade pip maturin
maturin develop --release --features extension-module,viz-interactive

maturin develop compile le module Rust et l’installe dans le venv (mode editable). --release compile en optimisé, ce qu’il faut pour tout usage réel : un build de débogage est bien plus lent à l’exécution. Les features font le reste : extension-module fait du binaire un module Python, et viz-interactive ajoute la visualisation, fenêtre interactive comprise (elle inclut viz, l’export PNG/SVG).

Après toute modification du Rust, relancer la même commande : sans --features …viz-interactive, le module recompilé perdrait la visualisation.

Vérifier l’installation

python -c "import pyrucast; c = pyrucast.Coords(2); n = c.add_node([0.0, 0.0]); print(c); print(n)"

Sortie attendue :

Coords: dim=2, configs=1 (active="default"), nodes=1 (0 collected), permutation: identity
<Node #0>

pyrucast.__features__ dit ce que porte le module installé : viz y figure quand la visualisation est compilée.

Premier script

Une ligne de 10 éléments entre deux nœuds, par la fonction libre line du module pyrucast.mesh :

import pyrucast

c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])

ligne = pyrucast.mesh.line(a, b, 10)
print(ligne)  # Mesh: 1 submesh(es) [#…], 10 cell(s) total

Trois niveaux de listing

Tout objet pyrucast se liste à trois niveaux : print(x) donne le résumé d’une ligne, repr(x) la structure, x.dump() le contenu complet — les listings résumé et complet de Cast3M. Le détail, avec un exemple, est dans Aspect informatique.

Fonctions libres et méthodes

Les opérateurs de pyrucast sont des fonctions libres, rangées par ce qu’elles produisent : tout ce qui rend un maillage est dans pyrucast.mesh. Quand une fonction prend en premier l’objet qu’elle transforme et rend un nouvel objet, elle existe en règle générale aussi en méthode de cet objet — les deux écritures sont équivalentes :

plaque = pyrucast.mesh.extrude(ligne, [0.0, 1.0], 5)  # fonction libre
plaque = ligne.extrude([0.0, 1.0], 5)  # la même, en méthode

La méthode sert à composer. Pour la bordure d’une plaque tirée de la ligne, les fonctions libres s’imbriquent et se lisent de l’intérieur ; les méthodes s’enchaînent et se lisent dans l’ordre des opérations :

bord = pyrucast.mesh.border(pyrucast.mesh.extrude(ligne, [0.0, 1.0], 5))  # imbriqué
bord = ligne.extrude([0.0, 1.0], 5).border()  # enchaîné
print(bord)  # Mesh: 1 submesh(es) [#…], 30 cell(s) total

La règle complète — quelles fonctions ont leur méthode, et pourquoi — est dans Conventions.

Voir le maillage

Une fenêtre interactive s’ouvre, s’il y a un écran :

import os

# Without a screen (continuous integration, a remote session), `plot()` would fail:
# we fall back to a file. That is the condition winit tests itself.
ecran = os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")
plaque.plot(save=None if ecran else "apercu.svg")

Aller plus loin

À partir d’ici, le chapitre Introduction présente le modèle d’objets, et la section Objets détaille chaque brique (coordonnées, maillage, champs, modèle physique…). Les chapitres Conduction thermique et Mécanique déroulent des problèmes complets de bout en bout.

  • Tutoriel : un parcours guidé, du maillage au calcul thermique et mécanique — Premiers pas.
  • Référence API Rust : publiée sur https://pyrucast.github.io/pyrucast/rust/, ou en local avec cargo doc --no-deps --lib --open.
  • Exemples Python complets et exécutables : dossier examples/ du dépôt (thermique, treillis, élasticité, poutres…).
  • Développer sur pyrucast (tests, doc, features) : Compilation et tests.
  • Tout construire d’un coup (prérequis + compilation + tests + les trois documentations + module avec visu interactive) : bash script/build.sh (Linux/macOS) ou .\script\build.ps1 (Windows) — voir Scripts « tout-en-un ».

Aspect informatique

Avant de détailler les objets un par un, ce chapitre donne la vue informatique de pyrucast, vue de Python : comment les objets vivent, et les quelques motifs qu’on retrouve partout. Quand cela éclaire l’usage, un encart dit ce qu’il y a dessous, côté Rust. Chaque section renvoie au chapitre qui en donne le détail.

Deux langages, un seul cœur

pyrucast est une librairie Rust exposée à Python.

  • Le cœur de calcul (structures de données, maillage, champs, assemblage, solveur) est écrit en Rust : typage fort, pas de ramasse-miettes runtime, performances natives.
  • Le binding Python est une fine couche pyo3, compilée par maturin en un module d’extension .so / .pyd. Les classes Python (pyrucast.Coords, pyrucast.Mesh, …) enveloppent des types Rust, et les fonctions de module (pyrucast.matrix.stiffness, pyrucast.solver.solve, …) appellent des fonctions Rust.

Le binding est un miroir 1:1 du Rust, sans logique propre : une méthode Rust reste une méthode, une fonction libre reste une fonction. Seules quelques parties sont écrites en Python, parce qu’elles n’ont rien à faire en Rust : les échanges avec gmsh et MED (mesh.from_gmsh, export.to_medcoupling…), qui appellent ces modules Python, et la couche pas à pas (pyrucast.stepping), qui orchestre les opérateurs. La table de correspondance exhaustive est dans Correspondance Rust ↔ Python ; la règle qui décide méthode vs fonction libre est dans Conventions.

   Script Python  ──►  module pyrucast (.so, pyo3)  ──►  crate Rust pyrucast
        |                      |                                |
   pyrucast.Coords        wrapper PyCoords                 struct Coords
   pyrucast.matrix.stiffness     fonction de module              ops::matrix::stiffness

Durée de vie des objets

En Python, rien à libérer à la main : un objet pyrucast vit tant qu’une variable ou un autre objet le référence — un maillage garde ses nœuds, un champ garde son support.

Les nœuds ont en plus leur propre compteur, dans les Coords : un nœud reste vivant tant qu’un maillage ou un champ l’utilise, même quand plus aucune variable ne le désigne. coords.gc() collecte ceux qui ne servent plus. Voir Coordonnées et Nœud.

Côté Rust, chaque objet vit derrière un Handle<T> — une référence comptée munie de son propre verrou — et la Coords porte un compteur par nœud. Le détail est dans Modèle mémoire.

Agrégats et zones

La plupart des conteneurs viennent par paires : une zone (Sub…) et son agrégat, une liste de zones qui s’utilise toujours de la même façon :

ZoneAgrégat
SubMeshMesh
SubFiniteElementSpaceFiniteElementSpace
SubNodeFieldNodeField
SubElementFieldElementField
SubModelModel
SubMatrixMatrix
SubEvolutionEvolution

Un agrégat s’utilise comme une liste de zones : len(mesh), mesh[i], for zone in mesh, et mesh.unit() quand il n’en a qu’une. Les zones ne se construisent pas directement (sauf SubEvolution, une courbe tabulée) : on construit l’agrégat, et mesh[i] donne une vue sur une zone.

Deux agrégats se composent par union : mesh_a | mesh_b. Les zones y sont partagées, pas copiées, et une zone déjà présente n’est pas ajoutée deux fois. L’opérateur +, lui, additionne des valeurs — champs ou matrices — et ne compose jamais (cf. Champ).

Côté Rust, ce motif est factorisé dans le trait Aggregate, et l’union s’y écrit a.union(&b) — voir Agrégat.

Trois niveaux de listing

Tout objet pyrucast se liste à trois niveaux, du plus court au plus complet :

  • print(x) ou str(x) — le résumé, une ligne : le listing résumé de Cast3M ;
  • repr(x) — la structure : compteurs, dimensions, noms, identifiants, sans jamais le contenu ;
  • x.dump() — le contenu : valeurs, coordonnées, connectivité, comme le listing complet de Cast3M. Il reste borné : au-delà de max_rows lignes (20 par défaut), la table est coupée et la coupure annoncée.

Sur des coordonnées qui portent les 11 nœuds d’une ligne de 10 éléments :

import pyrucast

c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
ligne = pyrucast.mesh.line(a, b, 10)

print(c)  # résumé : Coords: dim=2, configs=1 (active="default"), nodes=11 …
print(repr(c))  # structure : Coords { dim: 2, frame: Cartesian, … }
c.dump()  # contenu : la table des 11 nœuds, coordonnées et références

Seule exception, Array — le tableau que rendent les fonctions d’échange — n’a pas de dump() : simple tampon sans homologue Rust, ce n’est pas un objet pyrucast à proprement parler.

Côté Rust, les trois niveaux sont les traits Display, Debug et Dump.

Erreurs

En Python, une erreur signalée par pyrucast lève une RuntimeError, dont le message dit ce qui ne va pas. Les cas que Python nomme déjà gardent leur exception : IndexError pour un indice hors bornes, ZeroDivisionError pour une matrice divisée par zéro, KeyboardInterrupt sur un Ctrl+C.

Côté Rust, toute l’API publique renvoie Result<T, PyrucastError> : un seul type d’erreur pour toute la librairie — voir Conventions.

Persistance portable

pyrucast.save / pyrucast.load écrivent un graphe d’objets et le relisent en préservant le partage — deux champs sur un support restent deux champs sur un support. Le fichier est identique Linux ↔ Windows. Voir Sauvegarde et relecture.

Côté Rust, ce format repose sur un trait unique, Portable (serde + bincode) — voir Conventions et Modèle mémoire.

Pour le développeur Rust

L’organisation des fichiers Rust (où vit chaque morceau) est décrite dans Arborescence.

Objets

Cette partie décrit, un par un, les objets du modèle pyrucast : pour chacun, son principe (à quoi il sert, comment il est structuré) et son interface (API Rust et Python). Les objets sont présentés dans leur ordre de dépendance — chacun ne s’appuie que sur les précédents.

Coords ── Node                            le magasin de nœuds, et l'un d'eux
   │
   └── Mesh                               agrège des SubMesh
          ├── NodeField                   champ aux nœuds, sur un support POI1
          └── FiniteElementSpace          agrège des SubFiniteElementSpace
                 ├── ElementField         champ aux points de Gauss
                 └── Model                agrège des SubModel : physiques et contraintes
                        └── Matrix        matrice creuse, sortie d'assemblage

Evolution                                 agrège des SubEvolution : valeur tabulée selon une variable

Une distinction structure toute cette liste et se lit dans l’arborescence : Coords est le magasin (src/coords.rs), Node, Cell et Element sont des atomes insécables (src/atoms/), et tout le reste — les sept agrégats et leurs vues Sub* — sont des conteneurs divisibles (src/containers/), les seuls qui puissent être le sujet d’un opérateur. Voir Conventions.

Deux abstractions transverses traversent la liste et méritent d’être lues tôt :

  • l’Agrégat — la grammaire commune (len, [i], union |) de tous les conteneurs « parent / zones » (Mesh, FiniteElementSpace, Model, Matrix, NodeField, ElementField, Evolution) ;
  • le Champ (Field / SubField) — le contrat partagé entre NodeField et ElementField : composantes nommées, min/max, arithmétique scalaire et par composante.

Pour le contexte informatique (durée de vie des objets, motif zone/agrégat), voir Aspect informatique ; pour ce qu’il y a dessous côté Rust (Handle<T>, compteurs de références), Modèle mémoire. Pour les écrire dans un fichier et les relire tels qu’ils étaient, Sauvegarde et relecture.

Coordonnées (Coords)

Un Coords est le magasin des nœuds : il leur donne une identité et des coordonnées, en dimension fixée, dans une ou plusieurs configurations (référence, déformée…). C’est le premier objet de pyrucast — maillages, champs et espaces éléments finis s’y rattachent. On y crée les nœuds, et chacun se manipule par un Node, l’objet du chapitre suivant.

import pyrucast

c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])  # un pyrucast.Node
b = c.add_node([1.0, 0.0])
print(a.id, b.id)  # 0 1 — identifiants stables
print(c)  # Coords: dim=2, configs=1 (active="default"), nodes=2 …

ligne = pyrucast.mesh.line(a, b, 2)  # pose un nœud au milieu, id 2
print(c.refcount(a.id))  # 2 — la variable a, et la maille qui le contient
print(c.refcount(2))  # 2 — les deux mailles qui le contiennent
milieu = c.acquire(2)  # un Node pour un id existant
print(milieu.position())  # [0.5, 0.0]

Identité et durée de vie des nœuds

Chaque nœud créé reçoit un identifiant (node.id) stable et jamais réutilisé, valable pour toute la vie du Coords. C’est par lui que les maillages et les champs désignent leurs nœuds.

Un nœud ne se supprime pas : il n’existe aucune méthode pour le retirer, et ses coordonnées restent lisibles tant que le Coords existe. refcount(id) dit combien de références le désignent — chaque Node qui le tient, chaque maille qui le contient — et acquire(id) rend un nouveau Node pour un identifiant existant. Ces deux méthodes prennent un identifiant brut, pas un Node : passer un Node ajouterait la référence même qu’on cherche à compter.

Repère de révolution

Un Coords déclare aussi comment lire ses coordonnées : cartésien par défaut, ou axisymétrique — le plan méridien \( (r, z) \) d’un solide de révolution, avec \( x = r \ge 0 \) (rayon) et \( y = z \) (axe). C’est l’équivalent du OPTI MODE AXIS de Cast3M, et sa place est bien la géométrie : le repère change la mesure d’intégration elle-même,

\[ d\Omega = 2\pi r \, |J| \, d\xi, \]

donc rigidité, masse, conductivité, flux réparti, volumes et forces internes d’un coup — sur le corps comme sur ses bords. Le facteur \( 2\pi \) est celui de l’anneau complet : les masses, volumes et résultantes nodales sont ceux de la pièce de révolution entière.

import pyrucast

c = pyrucast.Coords.axisymmetric()
assert c.dim == 2 and c.is_axisymmetric
c.add_node([1.0, 0.0])  # r = 1, z = 0
try:
    c.add_node([-1.0, 0.0])  # x is a radius: it must be ≥ 0
except RuntimeError as erreur:
    print(erreur)

Un rayon négatif est refusé par add_node comme par set_position, plutôt que de ressortir en |J| négatif au fond d’une intégrale. Tout espace éléments finis bâti sur ces Coords hérite du repère, si bien qu’un corps et son bord ne peuvent pas diverger.

Côté mécanique, l’axisymétrie ajoute la déformation orthoradiale \( \varepsilon_{\theta\theta} = u_r/r \), qui relève du modèle et non de la géométrie : voir Élasticité linéaire. La thermique n’a rien à changer.

Côté visualisation, un tracé axisymétrique montre par défaut la section méridienne ; l’option revolve de plot la balaie pour dessiner le corps de révolution lui-même — voir Visualisation.

Plusieurs configurations

Une configuration est un jeu complet de coordonnées pour les mêmes nœuds : référence, déformée, prédite. add_config(nom) clone la configuration active sous un nouveau nom et rend son indice ; select(i) change la configuration active, et node.position() lit toujours dans celle-là.

import pyrucast

c = pyrucast.Coords(dim=2)
n = c.add_node([0.0, 0.0])

# Create a second configuration (a clone of the active one).
c2 = c.add_config("deformed")
print(c.names())  # ['default', 'deformed']

# Switch to the deformed configuration and change the coordinates.
c.select(c2)
n.set_position([0.1, 0.05])

# The coordinates read depend on the active configuration.
c.select(0)
print(n.position())  # [0.0, 0.0]  — configuration de référence
c.select(c2)
print(n.position())  # [0.1, 0.05] — configuration déformée
print(c.active)  # 1

Pour calculer dans une autre configuration — une matrice sur la déformée, une normale sur la paroi telle qu’elle est —, with coords.in_config(i): l’active le temps du bloc, puis rend la configuration d’avant, même si le bloc lève une exception. C’est la forme à préférer à une paire select(i) … select(0), qui laisse la mauvaise configuration active à la première exception. Les blocs s’imbriquent. La configuration active appartient au Coords, pas au bloc : un autre fil qui calcule sur le même Coords pendant le bloc la voit aussi.

import pyrucast

c = pyrucast.Coords(dim=2)
n = c.add_node([1.0, 0.0])
deformee = c.add_config("deformed")

# Le bloc voit `deformee` ; la configuration d'avant revient à la sortie,
# même après une exception.
with c.in_config(deformee):
    n.set_position([1.5, 0.0])
    x = n.position()

print(x)  # [1.5, 0.0]
print(n.position())  # [1.0, 0.0] — la référence, de nouveau active

Permutation pour le solveur

Une permutation optionnelle fixe l’ordre des nœuds pour le solveur sans toucher à leur identité : permutation[id] est la place du nœud id, et les matrices assemblées rangent leurs degrés de liberté dans cet ordre. Elle se pose à la main par set_permutation, avec une entrée par nœud créé (capacity) et aucune valeur en double ; clear_permutation revient à l’ordre des identifiants.

import pyrucast

c = pyrucast.Coords(dim=2)
c.add_node([0.0, 0.0])
c.add_node([1.0, 0.0])
c.add_node([0.5, 1.0])

# One entry per node created, no value twice.
print(c.capacity)  # 3
c.set_permutation([2, 0, 1])
print(c.permutation())  # [2, 0, 1]

# Back to the identity (None = identity).
c.clear_permutation()
print(c.permutation())  # None

Inspecter un Coords

dim, is_axisymmetric, names() et active décrivent le Coords ; print(c) le résume en une ligne, repr(c) en donne la structure et c.dump() la table complète des nœuds, coordonnées et références — les trois niveaux de listing.

Pourquoi plusieurs configurations dans un seul Coords

L’autre conception possible serait un Coords par configuration. pyrucast garde un seul jeu d’identités par domaine, et plusieurs configurations dedans :

Plusieurs configurations dans un Coords (pyrucast)Un Coords par configuration
identitél’id 42 désigne le même nœud dans toutes les configurations ; maillages et champs restent valides quelle que soit la configuration activel’id 42 de deux objets désigne deux nœuds différents : tout maillage ou champ à cheval doit porter une table de correspondance
changer de configurationun changement d’indice, rien à renuméroterun autre objet, et ce qui le référence à reconstruire
limitetoutes les configurations ont les mêmes nœudsdes jeux de tailles différentes sont possibles

Des Coords distincts servent aux domaines vraiment indépendants.

Côté Rust

En Rust, un Coords se partage par un Handle<Coords>, une référence comptée munie de son verrou, et chaque nœud porte en plus son propre compteur dans le Coords : c’est lui que refcount lit, et que Node, les maillages et les champs incrémentent. Le détail est dans Modèle mémoire. Les mêmes opérations s’écrivent ainsi :

use pyrucast::coords::Coords;

#[test]
fn le_repere_se_choisit_a_la_construction() {
    // Cartesian (the default): free dim.
    let plan = Coords::new(2).unwrap();
    assert!(!plan.is_axisymmetric());

    // Revolution: the dimension is necessarily 2, hence no argument.
    let axi = Coords::axisymmetric();
    assert_eq!(axi.dim(), 2);
    assert!(axi.is_axisymmetric());
}
#[test]
fn une_seconde_configuration_clone_la_courante() {
    let coords = Handle::new(Coords::new(2).unwrap());
    let id = coords.write().add_node(&[0.0, 0.0]).unwrap();

    let c2 = coords.write().add_config("deformed");
    coords.write().select(c2).unwrap();
    // the following `set_position` now change the "deformed" configuration.
    coords.write().set_position(id, &[0.1, 0.05]).unwrap();

    coords.write().select(0).unwrap();
    assert_eq!(coords.read().position(id).unwrap(), vec![0.0, 0.0]);
    coords.write().select(c2).unwrap();
    assert_eq!(coords.read().position(id).unwrap(), vec![0.1, 0.05]);
}

in_config y reçoit le bloc en fermeture, l’idiome Rust du with Python :

#[test]
fn calculer_dans_une_autre_configuration() -> pyrucast::Result<()> {
    use pyrucast::atoms::Node;
    use pyrucast::handle::Handle;
    use pyrucast::ops::coords as ops_coords;

    let coords = Handle::new(Coords::new(2)?);
    let n = Node::create_in(coords.clone(), &[1.0, 0.0])?;
    let deformee = coords.write().add_config("deformed");

    // Le bloc voit `deformee` ; la configuration d'avant revient à la sortie,
    // même si le bloc échoue ou panique.
    ops_coords::in_config(&coords, deformee, || n.set_position(&[1.5, 0.0]))?;
    let x = ops_coords::in_config(&coords, deformee, || n.position())?;

    assert_eq!(x, vec![1.5, 0.0]);
    assert_eq!(n.position()?, vec![1.0, 0.0]); // la référence, de nouveau active
    Ok(())
}
#[test]
fn une_permutation_renumerote_pour_le_solveur() {
    let coords = Handle::new(Coords::new(2).unwrap());
    // Trois nœuds créés ; ids = 0, 1, 2.
    coords.write().add_node(&[0.0, 0.0]).unwrap();
    coords.write().add_node(&[1.0, 0.0]).unwrap();
    coords.write().add_node(&[0.5, 1.0]).unwrap();

    // Permutation set by hand: one entry per node created.
    coords.write().set_permutation(vec![2, 0, 1]).unwrap();
    // permutation[0] = 2: the node with id 0 is at solver position 2.
    println!("{:?}", coords.read().permutation());

    // Back to the identity.
    coords.write().clear_permutation();
    assert!(coords.read().permutation().is_none());
}

Nœud (Node)

Un Node désigne un nœud d’un Coords. Il ne stocke pas de coordonnées : il porte son Coords et l’identifiant du nœud, et lit tout dans le Coords — les coordonnées qu’il rend sont donc celles de la configuration active. C’est l’objet qu’on passe partout où une fonction attend un nœud : pyrucast.mesh.line(a, b, 10), pyrucast.mesh.arc(…), pyrucast.mesh.circle(…).

Créer et retrouver un nœud

Un Node naît toujours d’un Coords :

  • coords.add_node([x, y, …]) crée le nœud et rend son Node ;
  • coords.acquire(id) rend un Node pour un nœud qui existe déjà — un nœud posé par un mailleur, par exemple, dont on connaît l’identifiant.

Interface

  • id — l’identifiant entier, stable dans son Coords ;
  • position() — ses coordonnées dans la configuration active ;
  • set_position([x, y, …]) — les réécrit, dans la configuration active ;
  • coords() — le Coords auquel il appartient ;
  • node | node — un Mesh POI1 sur les deux nœuds (la même union | que les agrégats, cf. Agrégat), et mesh | node ajoute un point à un Mesh POI1 d’une seule zone ;
  • print(n), repr(n) et n.dump() — les trois niveaux de listing.
import pyrucast

c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])

print(a.id)  # 0
print(a.position())  # [0.0, 0.0]
a.set_position([0.5, 0.5])

# Union de nœuds → maillage POI1 (deux points), puis un point de plus.
poi = a | b
print(poi)  # Mesh: 1 submesh(es) [#…], 2 cell(s) total
poi = poi | c.add_node([2.0, 0.0])
print(poi.cell_count)  # 3

print(a)  # <Node #0>
a.dump()  # Node #0 @ (0.500, 0.500)

Un Node compte comme une référence

Chaque Node compte comme une référence à son nœud, celle que coords.refcount(id) additionne avec celles des maillages et des champs : add_node et acquire en ajoutent une, et le Node la rend quand Python le détruit. Une seconde variable sur le même objet (b = a) ne compte pas deux fois : c’est le même Node.

C’est ce qui le distingue d’un simple identifiant. Un entier ne dit pas à quel Coords il appartient et ne compte pour rien ; le Node porte son Coords, lit ses coordonnées dans la bonne configuration, et le nœud le compte parmi ses références tant qu’il existe. Porter son Coords lui permet aussi d’être refusé là où il n’a pas sa place : une maille, un champ, une matrice posés sur un autre Coords lèvent une erreur plutôt que de prendre le nœud de ce Coords qui porte le même numéro.

Côté Rust

En Rust, un Node est une paire (Handle<Coords>, NodeId) qui tient son compte par RAII : Clone ajoute une référence dans le Coords, Drop la rend. Il se crée par Node::create_in(coords, &[x, y, …]), et Node::acquire(coords, id) en rend un de plus pour un identifiant existant — le verbe que Python porte sur le Coords, Rust le porte sur le nœud. Il expose id(), position(), set_position(&[…]) et coords(), qui rend le Handle<Coords> porté.

#[test]
fn un_noeud_est_un_compteur_de_references() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let n = Node::create_in(coords.clone(), &[1.0, 2.0])?;
    let id = n.id();
    let m = n.clone(); // une référence de plus
    assert_eq!(coords.read().refcount(id), 2);
    drop(n);
    drop(m); // chaque Drop rend la sienne
    assert_eq!(coords.read().refcount(id), 0);
    Ok(())
}

Le code interne peut manipuler des NodeId sans passer par Node, à condition de tenir lui-même le compte avec Coords::incref / Coords::decref : c’est ce que font les maillages et les champs. Le détail est dans Modèle mémoire.

Agrégat (Aggregate)

La plupart des conteneurs de pyrucast viennent par paires : une zone (SubMesh, SubFiniteElementSpace, SubNodeField, SubElementField, SubModel, SubMatrix, SubEvolution) et son agrégat (Mesh, FiniteElementSpace, NodeField, ElementField, Model, Matrix, Evolution), une liste de zones. Les sept agrégats s’utilisent exactement de la même façon et se composent de la même manière, par l’union |. Ce chapitre décrit ce contrat commun une fois pour toutes ; chaque chapitre d’objet n’en redonne que les spécificités.

Un agrégat ne copie jamais ses zones : il les partage. Deux agrégats peuvent porter la même zone, et la modifier par l’un se voit par l’autre.

Interface commune

OpérationSens
len(agg)nombre de zones
agg[i]vue sur la zone i (un Sub…) — jamais une copie
agg[i:j:k]nouvel agrégat du même type portant ces zones (slicing Python : pas, bornes négatives)
for zone in agg:parcourt les zones
agg.unit()la seule zone d’un agrégat qui n’en a qu’une, sinon une erreur
agg.add_sub(zone)ajoute une zone, en place
agg.add_subs(other)ajoute en place toutes les zones d’un autre agrégat, sans dédoublonner
agg | otherunion (voir plus bas)
print / repr / dump()les trois niveaux de listing

agg[i] est une vue, agg[i:j] un agrégat. L’indice entier rend la zone elle-même ; le slicing rend un agrégat du même type, qui partage les zones retenues.

Passer une seule zone à un opérateur. Les opérateurs prennent des agrégats, pas des vues : pyrucast.mesh.invert(maillage[1]) lève un TypeError (« ‘SubMesh’ object is not an instance of ‘Mesh’ »). Pour n’en traiter qu’une, passer par le slicing, qui rend un agrégat d’une seule zone : pyrucast.mesh.invert(maillage[1:2]).

unit() plutôt que [0]. agg[0] prend sans rien dire la première de plusieurs zones ; agg.unit() exige qu’il n’y en ait qu’une et lève sinon. Là où une seule zone a un sens — ajouter une maille à un maillage qu’on vient de créer —, unit() dit ce qu’on suppose.

Les zones ne se construisent pas seules

Une zone s’obtient par agg[i] ; on ne la construit pas directement. On construit l’agrégat — Mesh(coords, type), FiniteElementSpace(mesh), ElementField(fes, composantes) — ou on le reçoit d’un opérateur (pyrucast.model.heat_conduction(fes)…), puis on indexe pour atteindre une zone. Seule exception, SubEvolution : une courbe tabulée n’a pas de parent qui la définirait, elle se construit directement (cf. Évolution).

Composition : l’union |

Composer deux agrégats, c’est l’union, a | b. Elle rend un agrégat neuf et laisse ses deux opérandes intacts, avec la même règle pour les sept agrégats :

  1. Pas de doublon. Une zone déjà présente n’est pas ajoutée une seconde fois ; les zones gardent l’ordre de leur première apparition.
  2. Partage, pas copie. Les zones retenues sont celles des opérandes, pas des copies.
  3. Cohérence vérifiée. Ce qui doit être commun l’est, à l’ajout : par exemple, deux maillages sur des Coords différents se refusent (« mismatched Coords »).
  4. Fusion des champs. Pour NodeField et ElementField, les zones posées sur le même support sont fusionnées en fin d’union (voir Champ, Champ aux nœuds).

L’union accepte agrégats et zones, dans les deux sens :

ÉcritureRésultat
agg | aggl’union des deux listes
agg | zonela zone ajoutée en queue (ignorée si déjà présente)
zone | aggla même union, la zone en tête (déplacée si elle y était)
zone | zoneun agrégat neuf portant les deux zones

En place : add_sub / add_subs. Pour modifier un agrégat plutôt que d’en créer un, agg.add_sub(zone) ajoute une zone et agg.add_subs(other) toutes celles d’un autre agrégat, dans l’ordre. C’est de la concaténation : la cohérence est vérifiée, mais rien n’est dédoublonné ni fusionné. Pour composer, | reste la forme à préférer.

Les nœuds s’unissent aussi (cf. Nœud) :

ÉcritureRésultat
node | nodeun Mesh POI1 d’une zone, sur les deux nœuds
mesh | nodeajoute un point à un Mesh POI1 d’une seule zone, sinon une erreur

| compose, + calcule. L’union s’écrit toujours |. L’opérateur + (et -, *, /) est l’arithmétique — des champs (champ + 2.0, champ + champ) et des matrices (k + m) — et ne compose jamais : un Mesh + Mesh lève un TypeError.

Exemple

import pyrucast

c = pyrucast.Coords(dim=2)
ns = [c.add_node(p) for p in [(0, 0), (1, 0), (1, 1), (0, 1)]]

tri = pyrucast.Mesh(c, "TRI3")
tri.unit().add_cell([ns[0], ns[1], ns[2]])

qua = pyrucast.Mesh(c, "QUA4")
qua.unit().add_cell([ns[0], ns[1], ns[2], ns[3]])

# Union of two meshes (zones shared by handle).
mesh = tri | qua
print(len(mesh))  # 2 sous-maillages
print(mesh)  # Mesh: 2 submesh(es) [#…, #…], 2 cell(s) total

# In-place addition: `add_sub` for one zone, `add_subs` for all those of
# another aggregate (concatenation, without deduplication).
tri.add_subs(qua)
print(len(tri))  # 2 sous-maillages

Côté Rust

Un agrégat est une liste de Handle<Sub> : il partage ses zones par le compte de références de leur Handle, ce qui explique qu’il ne les copie jamais.

   Mesh                FiniteElementSpace        Model
   ├── Handle<SubMesh> ├── Handle<SubFES>        ├── Handle<SubModel>
   ├── Handle<SubMesh> ├── Handle<SubFES>        └── Handle<SubModel>
   └── …               └── …

Tout le contrat est porté par le trait Aggregate (src/aggregate.rs), qui le dérive de deux accesseurs, items() et items_mut() : len, is_empty, get(i), subset(indices) (sur lequel s’appuie le slicing Python), iter, unit, push, add_sub / add_subs, et l’union sous des noms explicites — a.union(&b), a.union_sub(&h) (zone en queue), a.union_sub_first(&h) (zone en tête), T::union_subs(&a, &b). « Pas de doublon » y veut dire : pas deux Handle sur le même objet (Handle::same_object). Deux points de personnalisation seulement : check_push, qui vérifie la cohérence à chaque ajout, et finalize, qui tourne en fin d’union et que les champs utilisent pour fusionner leurs zones.

C’est ce qui garantit qu’un sous-maillage, un sous-espace EF ou un sous-modèle s’indexent, se parcourent et se composent strictement de la même façon : aucun accès n’est réécrit par type. Un agrégat se câble par des macros : impl_aggregate!, qui pose aussi Index<usize> et IntoIterator, le plus souvent impl_aggregate_dump! pour le listing, et côté Python impl_aggregate_pymethods!.

Maillage (Mesh/SubMesh)

Un maillage est un agrégat de zones :

  • une zone (SubMesh) regroupe des mailles d’un seul type d’élément : que des TRI3, ou que des QUA4… Chaque maille y est une liste de nœuds d’un Coords ;
  • un maillage (Mesh) est une liste de zones, toutes sur le même Coords. C’est ce qui lui permet de mêler triangles et quadrangles : une zone par type.

Le type POI1 est le cas dégénéré : une maille, un nœud. Une zone POI1 est donc exactement une liste de nœuds, et c’est elle qui sert de support aux champs aux nœuds.

Cette page décrit l’objet maillage. Pour construire un maillage, on passe en général par les opérateurs de maillage (line, extrude, pave_surface…, cf. Mailler une géométrie) ; la construction à la main, maille par maille, sert surtout aux petits cas et aux tests.

Créer et lire un maillage

pyrucast.Mesh(coords, "TRI3") crée un maillage d’une zone vide de ce type (pyrucast.Mesh(coords), sans type, un maillage sans zone). mesh.unit() atteint cette zone, et zone.add_cell([n0, n1, n2]) y pose une maille et rend son indice. Les nœuds se donnent dans l’ordre local de l’élément, celui que décrit sa fiche, section « Élément de référence » ; un nombre de nœuds qui ne correspond pas au type est refusé, comme un nœud d’un autre Coords. Plusieurs types se composent par l’union |.

import pyrucast

c = pyrucast.Coords(2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
n3 = c.add_node([1.0, 1.0])
n4 = c.add_node([0.0, 1.0])
n5 = c.add_node([2.0, 0.5])

# Mesh(coords, type) : un maillage d'une zone vide ; unit() l'atteint,
# add_cell y pose une maille (nœuds dans l'ordre local de l'élément) et rend
# son indice.
quad = pyrucast.Mesh(c, "QUA4")
quad.unit().add_cell([a, b, n3, n4])  # 0
tri = pyrucast.Mesh(c, "TRI3")
tri.unit().add_cell([b, n5, n3])

# Deux types d'éléments, deux zones, composées par l'union.
mesh = quad | tri
print(mesh.element_types())  # ['QUA4', 'TRI3']
print(mesh.cell_counts())  # [1, 1]
print(mesh.cell_count)  # 2

# Lire : zone i, maille j, nœud k.
cell = mesh[1][0]  # la même vue que mesh.cell(1, 0)
print(len(cell), cell.element_type)  # 3 TRI3
print(cell[1].position())  # [2.0, 0.5]
print(mesh.node(0, 0, 2).id)  # 2
print(mesh.nearest_node([1.9, 0.4]).id)  # 4
Mesh
len(mesh), mesh[i], for zone in mesh, unit(), |l’interface des agrégats
element_types(), cell_counts()le type et le nombre de mailles de chaque zone
cell_countle nombre total de mailles
cell(i, j)la maille j de la zone i (= mesh[i][j])
node(i, j, k)le nœud k de cette maille, un Node
nearest_node(point)le nœud du maillage le plus proche d’un point
coords()le Coords des zones
duplicate()une copie non scellée (voir Scellement)
set_face_color((r, g, b))une couleur pour toutes les zones ; rend le maillage
plot(…)le tracé, cf. Visualisation
SubMesh (zone)
element_typele type d’élément, "TRI3"
cell_count, len(zone)le nombre de mailles
zone[j]la maille j
add_cell([nœuds])pose une maille, rend son indice
is_sealedla zone est-elle scellée ?
duplicate()une copie non scellée
face_colorla couleur de tracé (r, g, b), en lecture et en écriture

Une maille (Cell) est une vue : elle tient sa zone et son indice, et lit ses nœuds à la demande. len(cell) donne son nombre de nœuds, cell[k] le k-ième Node, cell.nodes() la liste ; cell.index et cell.element_type la situent. cell.dump() liste ses nœuds avec leurs coordonnées.

Les opérateurs de maillage s’appellent aussi en méthode : mesh.skin() est pyrucast.mesh.skin(mesh). Maillage, zone et maille s’affichent sur les trois niveaux de listing ; le dump() d’une zone donne sa table de connectivité.

Types d’éléments

Le type d’une zone se désigne par son nom.

TypeNœudsDim. topo.Cas usuel
POI110liste de nœuds
SEG221segment linéaire
TRI332triangle linéaire
QUA442quadrangle linéaire
TET443tétraèdre linéaire
PYRA553pyramide linéaire (raccord hexaèdre ↔ tétraèdre)
PENTA663prisme linéaire (extrusion d’un TRI3)
HEX883hexaèdre linéaire
SEG331segment quadratique
TRI662triangle quadratique
QUA882quadrangle quadratique (sérendipité)
QUA992quadrangle biquadratique (Lagrange complet, nœud central)
TET10103tétraèdre quadratique
PENTA15153prisme quadratique (sérendipité)
HEX20203hexaèdre quadratique (sérendipité)
HEX27273hexaèdre tri-quadratique (Lagrange complet, centres de face et nœud central)

Les huit derniers types sont quadratiques. Ils reprennent les sommets de leur parent linéaire, dans le même ordre, puis ajoutent les nœuds de milieu d’arête, dans l’ordre d’arêtes de la convention VTK ; QUA9 et HEX27 ajoutent encore leurs nœuds de face et leur nœud central. QUA8, PENTA15 et HEX20 sont sérendipité (nœuds d’arête seulement), les autres sont des Lagrange complets. Un espace éléments finis les accepte avec l’interpolation LAGRANGE2, qu’il prend de lui-même : pyrucast.FiniteElementSpace(mesh) choisit pour chaque zone le Lagrange de son degré (cf. Espace éléments finis).

Un maillage compte ses nœuds

Chaque fois qu’un nœud apparaît dans une maille, la zone prend une référence sur lui dans le Coords : un nœud partagé par quatre mailles en reçoit quatre, que coords.refcount(id) additionne avec celles des Node et des autres objets (cf. Coordonnées). La zone les rend toutes à sa destruction, quand plus rien ne la tient — ni variable Python, ni maillage, ni espace éléments finis, ni champ. Un add_cell refusé ne prend aucune référence.

Scellement

Un espace éléments finis, un champ ou une matrice indexent les mailles ou les nœuds de la zone sur laquelle ils sont posés. Ajouter une maille ensuite les laisserait sur une numérotation qui n’est plus la bonne. La zone est donc scellée dès qu’un tel objet la prend : sa connectivité est figée pour de bon, add_cell lève une RuntimeError (« submesh is sealed »), et zone.is_sealed vaut True. Ce qui scelle :

  • la construction d’un espace éléments finis scelle ses zones ;
  • une matrice scelle les zones POI1 qui portent ses lignes et ses colonnes ;
  • un champ aux nœuds scelle sa zone support, une zone POI1.

Contenir une zone ne la scelle pas : un maillage, une union |, une tranche la partagent sans la figer, et la zone peut grossir tant qu’aucun de ces objets ne s’y est posé. De même, pyrucast.mesh.merge_nodes(…, in_place=True), qui réécrit la connectivité en place, refuse une zone scellée.

Le champ aux nœuds mérite un mot. Posé sur une zone POI1, il prend cette zone pour support et la scelle. Posé sur une autre zone, il n’en indexe pas les mailles : il se pose sur son nuage de nœuds, une zone POI1 tirée d’elle (voir ci-dessous), et c’est ce nuage-là qui est scellé. La zone de départ reste modifiable ; si on la modifie, les champs déjà posés restent valides sur le nuage d’avant, et les suivants se posent sur un nouveau nuage.

Pour repartir d’une zone scellée, on en prend une copie : mesh.duplicate() rend un maillage neuf, non scellé, de même connectivité, sur les mêmes nœuds (seules leurs références augmentent). L’opérateur pyrucast.mesh.copy(mesh, new_nodes=…) fait de même, avec en plus le choix de créer des nœuds neufs aux mêmes positions.

mesh = pyrucast.Mesh(c, "TRI3")
mesh.unit().add_cell([a, b, n3])

pyrucast.FiniteElementSpace(mesh)  # indexe les mailles : scelle mesh[0]
print(mesh[0].is_sealed)  # True
# mesh[0].add_cell([b, n3, n4])  # RuntimeError: submesh is sealed: …

copie = mesh.duplicate()  # neuve, non scellée, sur les mêmes nœuds
copie.unit().add_cell([b, n3, n4])
print(copie.cell_count, mesh.cell_count)  # 2 1

Ce qu’une zone garde en cache

Une zone garde deux résultats tirés de sa connectivité. Chacun se calcule à la première demande, puis resert tel quel.

Son nuage de nœuds. pyrucast.mesh.to_poi1(mesh) rend, pour chaque zone, la zone POI1 de ses nœuds distincts, dans leur ordre de première apparition. Ce nuage est calculé une fois par zone, puis le même est rendu à chaque appel. Tout ce qui pose un champ aux nœuds sur un maillage passe par lui : NodeField(mesh, …), les supports des matrices d’un modèle — donc le résultat de solve(K, f) et le produit K * x —, pyrucast.node_field.restrict(champ, mesh), les sorties de divergence et de sum_to_nodes.

C’est ce qui permet à ces champs de se combiner. Deux champs aux nœuds ne s’additionnent ou ne se soustraient nœud à nœud que s’ils sont posés sur la même zone support ; sur deux supports distincts, même portant les mêmes nœuds, ils restent côte à côte, en deux zones (cf. Champ aux nœuds). Grâce au cache, une différence solve(K, f) - restrict(g, mesh) tombe sur le même support et se calcule directement. Le cache épargne aussi le dédoublonnage des nœuds, que chaque construction de modèle referait sinon.

L’index de ses nœuds. Une table « identifiant de nœud → rang dans la zone ». C’est par elle qu’un champ aux nœuds trouve la ligne d’un nœud (champ.value(nœud, "T"), set_value) et qu’une matrice trouve la ligne et la colonne d’un degré de liberté. Construite à la première recherche, elle sert ensuite à tous les champs et à toutes les matrices posés sur cette zone ; en pratique, c’est sur les zones POI1 support qu’elle se construit.

Les deux caches sont oubliés dès que la connectivité change (add_cell, merge_nodes(…, in_place=True)), et recalculés à la demande suivante : ils répondent toujours pour l’état courant de la zone. Un champ déjà posé garde son nuage, et restrict le porte sur le nouveau :

tri = pyrucast.Mesh(c, "TRI3")
tri.unit().add_cell([a, b, n3])

# Le nuage de nœuds d'une zone se calcule une fois : chaque appel rend
# ensuite la même zone POI1.
nuage = pyrucast.mesh.to_poi1(tri)
print(len(nuage | pyrucast.mesh.to_poi1(tri)))  # 1 — une seule zone

# Deux champs posés sur tri partagent donc leur support : ils se combinent
# nœud à nœud.
f = pyrucast.NodeField(tri, ["T"])
g = pyrucast.NodeField(tri, ["T"])
f[0].set_value(a, "T", 1.0)
g[0].set_value(a, "T", 0.25)
print((f - g).value(a, "T"))  # 0.75

# Ajouter une maille oublie le cache : le nuage suivant est une autre zone.
tri.unit().add_cell([b, n4, n3])
print(len(nuage | pyrucast.mesh.to_poi1(tri)))  # 2
print(f.node_count)  # 3 — f reste sur le nuage d'avant
r = pyrucast.node_field.restrict(f, tri)  # le porte sur le nouveau
print(r.node_count)  # 4

Les caches ne sont pas écrits par pyrucast.save : après une relecture, le nuage se recalcule, et un champ relu ne partage donc plus son support avec les champs qu’on pose ensuite sur le maillage relu. restrict(champ, mesh) l’y ramène.

Visualisation

mesh.plot(…) trace le maillage, chaque zone dans sa couleur (face_color), ou coloré par un champ. Voir Visualisation.

Côté Rust

Une zone stocke sa connectivité à plat : un Vec<NodeId> où la maille i occupe les entrées [i·n, (i+1)·n), n étant le nombre de nœuds du type. Elle porte aussi son Handle<Coords>, sa couleur, son drapeau de scellement et ses deux caches. Mesh est l’agrégat de Handle<SubMesh> ; il ne porte pas de Coords lui-même et le retrouve par sa première zone, et son check_push refuse une zone d’un autre Coords.

#[test]
fn un_maillage_se_compose_de_zones() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let a = Node::create_in(coords.clone(), &[0.0, 0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0, 0.0])?;
    let c = Node::create_in(coords.clone(), &[0.5, 1.0])?;

    let mut sm = SubMesh::new(coords.clone(), ElementType::TRI3);
    sm.add_cell(&[a.id(), b.id(), c.id()])?;

    let sm_handle = Handle::new(sm);
    let mut mesh = Mesh::empty(); // l'agrégat ne porte pas la `Coords`
    mesh.add_sub(sm_handle)?;
    assert_eq!(mesh.cell_count(), 1);
    Ok(())
}
  • Références. SubMesh::add_cell incrémente le compteur de chaque nœud et annule les incréments déjà faits si l’un échoue ; add_cell_taking adopte des références que l’appelant possède déjà (un nœud tout juste créé) ; le Drop de la zone les rend toutes, une fois, quand le dernier Handle disparaît (cf. Modèle mémoire).
  • Construction en bloc. add_cell prend le verrou d’écriture du Coords et vide les caches à chaque maille. SubMesh::from_connectivity(coords, type, connectivité) reçoit tout le tableau, le valide et incrémente ses nœuds en une seule prise de verrou, et Coords::add_nodes crée des nœuds d’un coup : c’est par là que les opérateurs de maillage produisent leurs zones.
  • Scellement. Les consommateurs passent par la fonction libre mesh::seal(&handle), qui ne prend le verrou d’écriture que si la zone n’est pas déjà scellée : sceller une zone qu’un lecteur tient déjà ne bloque pas. add_cell, add_cell_taking et remap_nodes rendent alors PyrucastError::MeshSealed.
  • Caches. Deux OnceLock, lus par node_index() et to_poi1(), vidés par chaque mutation de la connectivité et marqués #[serde(skip)].
  • Types d’éléments. ElementType porte le nom, le nombre de nœuds et la dimension topologique ; ce que l’élément sait de lui-même (repère, fonctions de forme, quadrature) vit dans src/atoms/element_kind/, un fichier par type — cf. Ajouter un élément fini.

Espace éléments finis (FiniteElementSpace/SubFiniteElementSpace)

Un maillage dit où sont les mailles ; un espace éléments finis dit comment on calcule dessus. Pour chaque zone du maillage, il fixe :

  • une interpolation : les fonctions de forme, qui donnent une grandeur en tout point d’une maille à partir de ses valeurs aux nœuds ;
  • une quadrature : les points de Gauss et leurs poids, où l’on évalue les intégrales.

C’est un agrégat de sous-espaces : un SubFiniteElementSpace par zone de maillage, dans le même ordre. Le maillage reste purement géométrique, et un même maillage peut porter plusieurs espaces.

Presque tout ce qui calcule s’appuie sur un espace : les physiques le reçoivent (model.heat_conduction(fes)), les champs aux points de Gauss posent leurs zones sur ses sous-espaces, et les opérateurs qui interpolent ou intègrent (element_field.interp_to_gauss, element_field.gradient, measure.integral…) le prennent en argument.

Construire un espace

pyrucast.FiniteElementSpace(mesh) pose sur chaque zone l’interpolation de Lagrange de son propre degré — "LAGRANGE1" sur des mailles linéaires, "LAGRANGE2" sur des quadratiques — et la quadrature "GAUSS" ; FiniteElementSpace.lagrange1(mesh) impose "LAGRANGE1" partout. Un argument interpolation= donné s’applique à toutes les zones à la fois, quadrature= de même, et FiniteElementSpace.with_choices(mesh, [(interpolation, quadrature), …]) en donne un couple par zone.

import pyrucast

# Un carré de côté 2 en deux triangles (zone 0), et son côté bas (zone 1).
c = pyrucast.Coords(2)
a = c.add_node([0.0, 0.0])
b = c.add_node([2.0, 0.0])
d = c.add_node([2.0, 2.0])
e = c.add_node([0.0, 2.0])
carre = pyrucast.Mesh(c, "TRI3")
carre.unit().add_cell([a, b, d])
carre.unit().add_cell([a, d, e])
bas = pyrucast.Mesh(c, "SEG2")
bas.unit().add_cell([a, b])
mesh = carre | bas

# Lagrange-1 et la quadrature de Gauss, sur chaque zone.
fes = pyrucast.FiniteElementSpace(mesh)
print(len(fes))  # 2
print(fes[0])  # SubFiniteElementSpace<TRI3, LAGRANGE1, GAUSS>: 3 Gauss point(s)
print(fes[1])  # SubFiniteElementSpace<SEG2, LAGRANGE1, GAUSS>: 2 Gauss point(s)

# Le même maillage, un point par maille : un autre espace.
centres = pyrucast.FiniteElementSpace(mesh, quadrature="REDUCED")
print(centres[0].gauss_count, centres[0].gauss_xi(0))  # 1 [0.333…, 0.333…]

# Un couple (interpolation, quadrature) par zone.
mixte = pyrucast.FiniteElementSpace.with_choices(
    mesh, [("LAGRANGE1", "GAUSS"), ("LAGRANGE1", "REDUCED")]
)
print([sub.quadrature for sub in mixte])  # ['GAUSS', 'REDUCED']

# Les zones portent maintenant un espace : leurs mailles sont figées.
print(carre.unit().is_sealed)  # True
interpolationmaillesce qu’elle interpole
"LAGRANGE1" (défaut des linéaires)linéaires : SEG2, TRI3, QUA4, TET4, PYRA5, PENTA6, HEX8une valeur par nœud
"LAGRANGE2" (défaut des quadratiques)quadratiques : SEG3, TRI6, QUA8, QUA9, TET10, PENTA15, HEX20, HEX27une valeur par nœud, sommets et nœuds milieux
"HERMITE3"SEG2une valeur et une pente par nœud : la poutre d’Euler-Bernoulli
"MODEL_EMBEDDED"toutesrien : la physique porte ses propres fonctions, comme la poutre de Timoshenko
quadraturepoints par maille
"GAUSS" (défaut)la règle du type de maille, voir Quadrature de Gauss
"REDUCED"un seul, au centre de la maille

Une interpolation de Lagrange doit avoir le degré des mailles : c’est ce que fait le choix par défaut, zone par zone, si bien qu’un maillage qui mêle zones linéaires et quadratiques se pose sans argument. Une interpolation donnée explicitement, elle, vaut pour toutes les zones. "REDUCED" sert à porter une valeur par maille, par exemple l’intégrale que rend element_field.cell_integral ; les coques l’emploient aussi, en interne, pour leur cisaillement transverse.

La construction est refusée pour :

  • un maillage sans zone, ou une liste with_choices qui ne compte pas un couple par zone ;
  • une zone POI1 : un point n’a pas d’élément de référence ;
  • une interpolation que le type de maille n’admet pas ("LAGRANGE2" sur des TRI3, "HERMITE3" ailleurs que sur des SEG2) ;
  • des mailles de dimension supérieure à celle des Coords (des TRI3 dans des Coords 1-D) ;
  • un nom d’interpolation ou de quadrature inconnu.

Construire l’espace scelle les zones de maillage. Leurs mailles sont figées : add_cell y lève ensuite une erreur. Tout ce qui se pose sur l’espace — les valeurs d’un champ aux points de Gauss, les lignes d’une matrice, la coloration — est rangé par rang de maille, et une maille ajoutée décalerait tout. Les positions des nœuds, elles, restent libres (voir Déplacer le maillage). Le sceau est décrit avec le maillage.

Lire un espace

fes[i], fes.unit() et for sub in fes donnent les sous-espaces. Un sous-espace donne son élément de référence (points de Gauss, poids, fonctions de forme et leurs dérivées en \( \xi \)) et, pour chaque maille, les grandeurs physiques tirées de ses nœuds : le jacobien \( J \), sa mesure \( |J| \) et les dérivées \( \partial N_i/\partial x_a \). Un point s’y désigne par le rang de la maille dans la zone et le rang du point de Gauss dans la maille.

sub = fes[0]  # le sous-espace des triangles
print(sub.element_type, sub.interpolation, sub.quadrature)  # TRI3 LAGRANGE1 GAUSS
print(sub.ref_dim, sub.space_dim, sub.nodes_per_cell)  # 2 2 3
print(sub.cell_count, sub.gauss_count)  # 2 3

# Les tables de référence : le point de Gauss 0, son poids, N_i, ∂N_i/∂ξ_k.
print(sub.gauss_xi(0), sub.gauss_weight(0))  # [0.5, 0.0] 0.16666666666666666
print(sub.n_at_g(0))  # [0.5, 0.5, 0.0]
print(sub.dn_at_g(0))  # [-1.0, -1.0, 1.0, 0.0, 0.0, 1.0]

# La maille 0 au point 0 : J, |J| et ∂N_i/∂x_a, tirés de ses nœuds.
print(sub.jacobian(0, 0))  # [2.0, 2.0, 0.0, 2.0]
print(sub.det_jacobian(0, 0))  # 4.0
print(sub.dn_dx(0, 0))  # [-0.5, 0.0, 0.5, -0.5, 0.0, 0.5]

# Une maille vue seule : son aire est la somme des w_g |J_g|.
maille = fes.element(0, 1)  # ou sub[1]
print(sum(maille.gauss_weight(g) * maille.det_jacobian(g) for g in range(3)))  # 2.0
print(len(sub.elements()), len(fes.elements(1)))  # 2 1

# Chaque sous-espace rend sa zone : la même, pas une copie.
print(len(mesh | sub.submesh()))  # 2 : l'union ne la compte pas deux fois
# mesh() rend une zone par sous-espace, dans l'ordre.
print(len(fes.mesh()), len((fes | centres).mesh()))  # 2 4
print(len(fes | fes), len(fes | pyrucast.FiniteElementSpace(mesh)))  # 2 4
FiniteElementSpace
len(fes), fes[i], for sub in fes, unit(), |l’interface des agrégats
mesh()le maillage couvert : la zone de chaque sous-espace, dans l’ordre
element(i, maille), elements(i)la vue sur une maille du sous-espace i, ou sur toutes
dump()le contenu, points de Gauss compris
SubFiniteElementSpace (sous-espace)
element_type, interpolation, quadraturele type de maille et les deux choix de la zone
submesh()sa zone de maillage, partagée
ref_dim, space_dim, nodes_per_cellla dimension de l’élément de référence, celle des Coords, les nœuds par maille
is_axisymmetricsi les Coords décrivent un corps de révolution
cell_count, gauss_countles mailles, les points de Gauss par maille
gauss_xi(g), gauss_weight(g)la position du point g dans l’élément de référence, son poids
n_at_g(g), dn_at_g(g)\( N_i \) et \( \partial N_i/\partial\xi_k \) au point g
jacobian(maille, g), det_jacobian(maille, g), dn_dx(maille, g)\( J \), \( \lvert J\rvert \) et \( \partial N_i/\partial x_a \) d’une maille au point g
sub[maille], element(maille), elements(), len(sub)les vues sur les mailles

Une vue de maille (Element) donne les mêmes grandeurs sans le rang de la maille — jacobian(g), det_jacobian(g), dn_dx(g)… —, les mêmes dimensions (gauss_count, nodes_per_cell, ref_dim, space_dim), plus index, cell() et nodes().

Les tableaux sont rendus à plat, ligne par ligne :

méthodelongueurcase
n_at_g(g)nodes_per_cell[i] = \( N_i \)
dn_at_g(g)nodes_per_cell × ref_dim[i * ref_dim + k] = \( \partial N_i/\partial\xi_k \)
jacobian(maille, g)space_dim × ref_dim[a * ref_dim + k] = \( \partial x_a/\partial\xi_k \)
dn_dx(maille, g)nodes_per_cell × space_dim[i * space_dim + a] = \( \partial N_i/\partial x_a \)

Ces méthodes lisent les fonctions de forme géométriques, celles qui placent les points de la maille. Sous "LAGRANGE1" et "LAGRANGE2", ce sont aussi celles du champ ; sous "HERMITE3" et "MODEL_EMBEDDED", elles ne décrivent que la géométrie (voir plus bas).

Un espace par formulation, construit une fois. Chaque construction crée des sous-espaces neufs : deux FiniteElementSpace(mesh) sont deux espaces, même avec les mêmes choix, et les champs posés sur l’un ne se combinent pas point à point avec ceux de l’autre (voir Champ aux points de Gauss). On construit donc l’espace une fois et on le passe à tous les opérateurs. L’union | garde chaque sous-espace une seule fois et refuse deux espaces posés sur des Coords différents.

Déplacer le maillage

Les grandeurs physiques ne sont pas stockées : chaque appel les recalcule à partir des positions actuelles des nœuds. Déplacer un nœud suffit, sans rien reconstruire ni signaler :

# Allonger le côté bas : les deux zones voient la nouvelle position de b.
b.set_position([4.0, 0.0])
print(fes[0].det_jacobian(0, 0))  # 8.0, au lieu de 4.0
print(fes[1].det_jacobian(0, 0))  # 2.0, au lieu de 1.0

L’espace ne contrôle pas la forme des mailles, ni à la construction ni après un déplacement. det_jacobian rend une valeur absolue : une maille retournée (ses nœuds parcourus dans l’autre sens) mesure autant que la même à l’endroit. Une maille aplatie mesure 0, et dn_dx la refuse, faute de pouvoir inverser son jacobien.

Ce qui est mis en cache

Deux choses, gardées dans chaque sous-espace pour toute sa durée de vie :

  • Les tables de référence, calculées à la construction : positions et poids des points de Gauss, \( N_i(\xi_g) \) et \( \partial N_i/\partial\xi_k(\xi_g) \) — sous "HERMITE3", en plus, les fonctions du champ et leurs dérivées secondes. Elles ne dépendent que du type de maille, de l’interpolation et de la quadrature : calculées une fois, elles servent ensuite à toutes les mailles et à tous les opérateurs. Leur taille ne dépend pas du nombre de mailles, quelques milliers de nombres au plus (un HEX27 à 27 points).
  • La coloration des mailles, calculée au premier usage : un partage des mailles en couleurs, deux mailles de même couleur n’ayant aucun nœud commun. Les mailles d’une couleur écrivent alors en parallèle sans se gêner, dans l’assemblage des matrices (matrix.stiffness, matrix.mass, matrix.tangent…) et dans les opérateurs qui accumulent aux nœuds (node_field.internal_forces, node_field.external_forces, node_field.divergence, node_field.sum_to_nodes). La calculer demande un passage sur toute la connectivité ; la garder évite de le refaire à chaque appel, alors qu’un calcul non linéaire réassemble à chaque itération. Elle ne dépend que de la connectivité, que le sceau fige.

Ce qui n’est pas gardé : \( J \), \( |J| \), \( \partial N_i/\partial x_a \) et la position réelle des points de Gauss. Les garder coûterait une matrice par point de Gauss — de la mémoire en proportion du maillage — et les rendrait fausses au premier nœud déplacé. Les recalculer coûte une somme sur les nœuds de la maille, que chaque assemblage fait une fois par point de Gauss avant de s’en servir pour tous les termes de la matrice élémentaire.

Sauvegarde

Un espace se sauvegarde avec ses zones de maillage et ses Coords. À la lecture, le sceau des zones est conservé, et les tables de référence sont relues telles quelles. La coloration n’est pas écrite : elle se recalcule au premier usage. Un espace et les champs posés dessus, sauvegardés dans un même fichier, restent liés à la lecture : les champs relus sont posés sur l’espace relu.

Théorie

Élément de référence

Chaque type de maille a un élément de référence \( \hat K \), décrit dans un repère \( \xi \), et une numérotation locale de ses nœuds :

typerepère \( \xi \)ordre des nœuds
SEG2\( \xi \in [-1, +1] \)nœud 0 en \( \xi = -1 \), nœud 1 en \( \xi = +1 \)
TRI3\( \xi, \eta \in [0, 1] \), \( \xi + \eta \le 1 \)\( (0,0), (1,0), (0,1) \), sens trigonométrique
QUA4\( \xi, \eta \in [-1, +1] \)\( (-1,-1), (1,-1), (1,1), (-1,1) \), sens trigonométrique
TET4\( \xi, \eta, \zeta \in [0, 1] \), \( \xi + \eta + \zeta \le 1 \)\( (0,0,0), (1,0,0), (0,1,0), (0,0,1) \) ; face 0-1-2 en sens trigonométrique vue du nœud 3
PYRA5\( \zeta \in [0, 1] \), \( \xi, \eta \in [-(1-\zeta), +(1-\zeta)] \)base carrée en sens trigonométrique vue du sommet (nœuds 0 à 3 en \( \zeta = 0 \)), puis le sommet : \( (-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0), (0,0,1) \)
PENTA6\( \xi, \eta \in [0, 1] \), \( \xi + \eta \le 1 \), \( \zeta \in [0, 1] \)triangle du bas (nœuds 0 à 2 en \( \zeta = 0 \)) puis triangle du haut (nœuds 3 à 5 en \( \zeta = 1 \)), tous deux en sens trigonométrique
HEX8\( \xi, \eta, \zeta \in [-1, +1] \)face du bas (nœuds 0 à 3) puis face du haut (nœuds 4 à 7), en sens trigonométrique

Les types quadratiques reprennent le repère et les sommets de leur parent linéaire, puis ajoutent les nœuds de milieu d’arête, dans l’ordre des arêtes de la convention VTK :

typeparentnœuds ajoutés, arête \( (a,b) \)
SEG3SEG2nœud 2 sur \( (0,1) \) (\( \xi = 0 \))
TRI6TRI33 sur \( (0,1), (1,2), (2,0) \)
QUA8QUA44 sur \( (0,1), (1,2), (2,3), (3,0) \)
QUA9QUA4les 4 arêtes de QUA8, puis un nœud central 8 en \( (0,0) \)
TET10TET46 sur \( (0,1), (1,2), (2,0), (0,3), (1,3), (2,3) \)
PENTA15PENTA69 : bas \( (0,1),(1,2),(2,0) \), haut \( (3,4),(4,5),(5,3) \), verticales \( (0,3),(1,4),(2,5) \)
HEX20HEX812 : bas \( (0,1),(1,2),(2,3),(3,0) \), haut \( (4,5),(5,6),(6,7),(7,4) \), verticales \( (0,4),(1,5),(2,6),(3,7) \)
HEX27HEX8les 12 arêtes de HEX20, puis 6 centres de face (x∓, y∓, z∓, nœuds 20 à 25) et un centre de volume 26

QUA8, HEX20 et PENTA15 sont sérendipité (nœuds d’arête seulement) ; SEG3, TRI6, TET10, QUA9 et HEX27 sont des Lagrange complets (QUA9/HEX27 : produits tensoriels \( Q_2 \), avec nœuds de face et central).

Les mailleurs produisent leurs mailles dans ces conventions, l’export VTK les écrit telles quelles et la lecture gmsh y remet ses nœuds milieux. La fiche de chaque type, avec ses fonctions de forme et sa quadrature, est dans le catalogue des éléments ; cette section décrit ce qui leur est commun.

Transformation isoparamétrique

Soit une maille à \( n \) nœuds, de coordonnées physiques \( \mathbf{x}_1, \dots, \mathbf{x}_n \in \mathbb{R}^{d_s} \), avec \( d_s \) la dimension des Coords. La transformation géométrique \( \chi : \hat{K} \to K \), qui envoie l’élément de référence sur la maille, est interpolée par les fonctions de forme :

\[ \mathbf{x}(\xi) = \chi(\xi) = \sum_{i=1}^{n} N_i(\xi)\, \mathbf{x}_i \]

C’est l’hypothèse isoparamétrique : la géométrie s’interpole comme un champ. En Lagrange-1, \( \chi \) est affine sur les simplexes (SEG2, TRI3, TET4), bilinéaire sur QUA4 et trilinéaire sur HEX8.

Fonctions de forme de Lagrange

Une fonction de forme de Lagrange vérifie la propriété de Kronecker : \( N_i(\xi_j) = \delta_{ij} \) aux nœuds de référence. En Lagrange-1 :

SEG2 (\( \xi \in [-1, +1] \)) : \[ N_1(\xi) = \frac{1 - \xi}{2}, \qquad N_2(\xi) = \frac{1 + \xi}{2} \]

TRI3 (coordonnées barycentriques sur le simplexe \( \xi + \eta \le 1 \)) : \[ N_1 = 1 - \xi - \eta, \qquad N_2 = \xi, \qquad N_3 = \eta \]

QUA4 (bilinéaire sur \( [-1, +1]^2 \)) : \[ N_1 = \tfrac{1}{4}(1-\xi)(1-\eta), \quad N_2 = \tfrac{1}{4}(1+\xi)(1-\eta), \quad N_3 = \tfrac{1}{4}(1+\xi)(1+\eta), \quad N_4 = \tfrac{1}{4}(1-\xi)(1+\eta) \]

TET4 (coordonnées barycentriques sur le simplexe \( \xi + \eta + \zeta \le 1 \)) : \[ N_1 = 1 - \xi - \eta - \zeta, \quad N_2 = \xi, \quad N_3 = \eta, \quad N_4 = \zeta \]

HEX8 (trilinéaire sur \( [-1, +1]^3 \)). Pour le nœud \( i \) de coordonnées de référence \( (\xi_i, \eta_i, \zeta_i) \in \{-1, +1\}^3 \), \[ N_i(\xi, \eta, \zeta) = \tfrac{1}{8}\, (1 + \xi_i\, \xi)\, (1 + \eta_i\, \eta)\, (1 + \zeta_i\, \zeta) \]

PENTA6 (prisme, produit d’un TRI3 par un SEG2 linéaire sur \( \zeta \in [0, 1] \)). Avec les coordonnées barycentriques du triangle \( L_1 = 1 - \xi - \eta \), \( L_2 = \xi \), \( L_3 = \eta \), \[ N_j(\xi, \eta, \zeta) = L_j\,(1 - \zeta) \quad (j = 1, 2, 3), \qquad N_{j+3}(\xi, \eta, \zeta) = L_j\,\zeta \quad (j = 1, 2, 3) \]

PYRA5 a des fonctions rationnelles, qui recollent la base carrée au sommet : voir sa fiche.

En Lagrange-2, avec les coordonnées barycentriques \( L_i \) pour les simplexes :

  • SEG3 : \( N_0 = \tfrac12\xi(\xi-1),\; N_1 = \tfrac12\xi(\xi+1),\; N_2 = 1-\xi^2 \) ;
  • TRI6 / TET10 : sommets \( L_i(2L_i-1) \), milieux d’arête \( 4 L_a L_b \) ;
  • QUA9 : produit tensoriel \( N_i(\xi,\eta) = \ell_{a}(\xi)\,\ell_{b}(\eta) \) des trois fonctions de Lagrange 1-D \( \ell_{-}(t)=\tfrac12 t(t-1),\; \ell_0(t)=1-t^2,\; \ell_{+}(t)=\tfrac12 t(t+1) \) ;
  • HEX27 : produit tensoriel 3-D \( N_i(\xi,\eta,\zeta) = \ell_{a}(\xi)\,\ell_{b}(\eta)\,\ell_{c}(\zeta) \) des mêmes fonctions ;
  • QUA8 (sérendipité) : sommet \( \tfrac14(1+\xi_i\xi)(1+\eta_i\eta)(\xi_i\xi+\eta_i\eta-1) \) ; milieu \( \tfrac12(1-\xi^2)(1+\eta_i\eta) \) ou \( \tfrac12(1+\xi_i\xi)(1-\eta^2) \) selon l’arête ;
  • HEX20 (sérendipité) : sommet \( \tfrac18(1+\xi_i\xi)(1+\eta_i\eta)(1+\zeta_i\zeta)(\xi_i\xi+\eta_i\eta+\zeta_i\zeta-2) \) ; milieu \( \tfrac14(1-\xi^2)(1+\eta_i\eta)(1+\zeta_i\zeta) \) et ses permutations ;
  • PENTA15 (sérendipité) : produit du TRI6 par un facteur quadratique en \( \zeta \), corrigé aux sommets.

Toutes forment une partition de l’unité, \( \sum_i N_i(\xi) = 1 \) en tout \( \xi \) : un champ constant est reproduit exactement. Les tests le vérifient, avec Kronecker, et recoupent les dérivées des types quadratiques par différences finies.

Dérivées de référence

Les dérivées de référence \( \partial N_i / \partial \xi_k \) s’obtiennent en dérivant les formules. En Lagrange-1, elles sont constantes sur SEG2, TRI3 et TET4 ; sur les autres types, et sur tous les quadratiques, elles sont polynomiales en \( \xi \). La partition de l’unité dérivée donne \( \sum_i \partial N_i / \partial \xi_k = 0 \) dans chaque direction \( k \), identité elle aussi testée.

Fonctions de forme d’Hermite (HERMITE3)

Les fonctions de Lagrange sont C⁰ : le champ est continu d’une maille à la suivante, sa dérivée ne l’est pas. Cela suffit aux équations du second ordre. Une poutre d’Euler-Bernoulli obéit à \( (EIw'')'' = q \), du quatrième ordre, dont la forme faible exige un champ C¹ : flèche et pente continues.

C’est ce que fournit "HERMITE3", sur SEG2 :

\[ \begin{aligned} H_1 &= \tfrac14(2 - 3\xi + \xi^3), &\qquad H_2 &= \tfrac14(1 - \xi - \xi^2 + \xi^3),\\ H_3 &= \tfrac14(2 + 3\xi - \xi^3), &\qquad H_4 &= \tfrac14(-1 - \xi + \xi^2 + \xi^3). \end{aligned} \]

La propriété de Kronecker y porte sur deux grandeurs : à chaque extrémité, une fonction vaut 1 et a une pente nulle, l’autre vaut 0 et a une pente 1, et les deux fonctions de l’extrémité opposée s’y annulent avec leur pente. D’où l’ordre des degrés de liberté, \( [w_A,\ w'_A,\ w_B,\ w'_B] \) : une valeur et une pente par nœud.

Quatre fonctions pour deux nœuds. Le nombre de fonctions de forme n’est plus le nombre de nœuds : les tables du champ sont dimensionnées sur le nombre de fonctions, celles de la géométrie sur le nombre de nœuds.

L’élément est sous-paramétrique. Un SEG2 reste un segment droit, interpolé en Lagrange-1 quel que soit le champ qu’il porte. Le sous-espace tabule donc deux bases : la géométrique, qui donne le jacobien \( \partial x/\partial\xi \) et que lisent n_at_g, dn_at_g et dn_dx, et celle du champ, qui interpole l’inconnue. Sous Lagrange, les deux coïncident et seule la géométrique est stockée.

Dérivées secondes. "HERMITE3" est la seule interpolation à tabuler \( \partial^2 N_i/\partial\xi^2 \) : la courbure est une grandeur primaire d’un élément C¹. Sur SEG2, ces dérivées sont linéaires en \( \xi \) : la courbure varie dans la maille. Le passage au physique est une règle de chaîne, sans terme en \( \partial J/\partial\xi \) puisqu’un segment a un jacobien constant :

\[ \frac{\partial^2 N_i}{\partial x^2} = \frac{1}{J^2}\, \frac{\partial^2 N_i}{\partial \xi^2}, \qquad J = \frac{L}{2}. \]

La pente de référence n’est pas la rotation. \( H_2 \) et \( H_4 \) portent une pente \( \partial w/\partial\xi \), alors que le degré de liberté d’une poutre est \( \theta = \partial w/\partial x \). Les deux diffèrent du jacobien, \( \partial w/\partial\xi = J\,\partial w/\partial x \). Ce facteur n’est pas dans les fonctions de forme : c’est un passage référence → physique, appliqué là où sont appliqués tous les autres jacobiens. Avec un \( L \) dans la fonction de forme, la base dépendrait de la maille et ne pourrait plus être tabulée une fois par type.

Ce qui le vérifie. La raideur d’Euler-Bernoulli est l’oracle capable de mettre cette base en défaut :

\[ K = \int_0^L EI \left(\frac{\partial^2 N}{\partial x^2}\right)^{\!\top} \frac{\partial^2 N}{\partial x^2}\, dx = \frac{EI}{L^3} \begin{bmatrix} 12 & 6L & -12 & 6L \\ 6L & 4L^2 & -6L & 2L^2 \\ -12 & -6L & 12 & -6L \\ 6L & 2L^2 & -6L & 4L^2 \end{bmatrix}. \]

tests/hermite.rs intègre le membre de gauche depuis la base tabulée et le compare à la forme fermée, à la précision machine. Une erreur dans les fonctions, dans leurs dérivées secondes ou dans le facteur jacobien des pentes apparaît dans cette comparaison. L’intégrande étant quadratique en \( \xi \), les deux points de Gauss du SEG2 l’intègrent exactement.

Pas de base du tout : MODEL_EMBEDDED

Un élément dont la matrice élémentaire est une forme fermée n’évalue jamais de fonction de forme : son intégrale a été faite une fois, à la main, et seul le résultat figure dans le code. La poutre de Timoshenko exacte en est un : ses fonctions de forme dépendent du matériau, à travers \( \Phi = 12EI/(G A_s L^2) \), et aucun espace ne peut les tabuler.

Y déclarer une interpolation de Lagrange affirmerait quelque chose que la physique n’utilise pas, et qui serait faux : la forme fermée vient de fonctions cubiques et quadratiques, pas linéaires. "MODEL_EMBEDDED" déclare qu’il n’y a pas de base du champ :

interpolationgéométriechamp
"LAGRANGE1" / "LAGRANGE2"Lagrangela même
"HERMITE3"Lagrange-1Hermite, 4 fonctions
"MODEL_EMBEDDED"Lagrangeaucune

La géométrie reste entièrement définie — coordonnées, jacobien, mesure — et tout le reste fonctionne comme sur un autre espace : champ matériau, champs aux points de Gauss, assemblage, coloration. Seule l’interpolation de l’inconnue n’appartient pas à l’espace, et les opérateurs qui en ont besoin lèvent une erreur qui le dit, plutôt que de se rabattre sur la base géométrique :

opérateurce qu’il interpole
model.fluxla fonction test d’une charge répartie
element_field.deformationle déplacement, pour la déformation
element_field.shell_deformationle déplacement d’une coque
element_field.interp_to_gaussun champ aux nœuds, porté aux points de Gauss
measure.integralle champ intégré

Le repli serait plausible, et c’est ce qui le rendrait dangereux : sur une poutre, la charge répartie cohérente vaut \( qL/2 \) et \( qL^2/12 \) en moment ; une base linéaire donnerait \( qL/2 \) sans moment.

La visualisation, elle, reste sur la base géométrique : ce qu’elle colorie est ce qu’elle dessine, un segment droit ou une facette plane. C’est une image, pas une valeur calculée.

La formulation fournit alors elle-même ce que l’espace ne peut plus interpoler : element_field.beam_deformation évalue les déformations de la poutre depuis les fonctions de forme de l’élément, et reçoit pour cela le champ matériau, dont \( \Phi \) dépend.

Quadrature de Gauss

Pour intégrer une fonction \( f \) sur une maille, on se ramène à l’élément de référence par le changement de variables \( \mathbf{x} = \chi(\xi) \) :

\[ \int_K f(\mathbf{x})\, d\mathbf{x} = \int_{\hat{K}} f(\chi(\xi))\, |J(\xi)|\, d\xi \approx \sum_{g=1}^{n_g} w_g\, f(\chi(\xi_g))\, |J(\xi_g)| \]

avec \( |J| \) la mesure du jacobien, définie plus bas. Le couple \( (\xi_g, w_g) \) est la règle de quadrature. La règle "GAUSS" de chaque type intègre exactement sa matrice de masse sur une maille à géométrie droite :

type\( n_g \)règleexactitude polynomiale
SEG22Gauss-Legendre sur \([-1, +1]\) : \( \xi_g = \pm 1/\sqrt{3} \), \( w_g = 1 \)\( \deg \le 3 \)
TRI33Hammer, milieux d’arête : \( (\tfrac{1}{2}, 0), (\tfrac{1}{2}, \tfrac{1}{2}), (0, \tfrac{1}{2}) \), \( w_g = 1/6 \)\( \deg \le 2 \)
QUA44produit 2×2 de Gauss-Legendre : \( \xi_g = (\pm 1/\sqrt{3}, \pm 1/\sqrt{3}) \), \( w_g = 1 \)\( \deg \le 3 \) par direction
TET44Hammer : \( \alpha = \tfrac{5 - \sqrt{5}}{20} \), \( \beta = \tfrac{5 + 3\sqrt{5}}{20} \), points permutés, \( w_g = 1/24 \)\( \deg \le 2 \)
PYRA58produit conique : 2×2 Gauss-Legendre sur la section carrée × Gauss-Jacobi 2 points en \( \zeta \) (poids \( (1-\zeta)^2 \), nœuds \( \tfrac13 \mp \tfrac{\sqrt{10}}{15} \))\( \deg \le 3 \)
PENTA66produit de la règle TRI3 (3 points, \( w = 1/6 \)) et de Gauss-Legendre 2 points sur \( \zeta \in [0, 1] \) (\( \zeta_g = \tfrac{1}{2} \pm \tfrac{1}{2\sqrt{3}} \), \( w = 1/2 \))\( \deg \le 2 \) en \( (\xi, \eta) \), \( \le 3 \) en \( \zeta \)
HEX88produit 2×2×2 de Gauss-Legendre : \( \xi_g = (\pm 1/\sqrt{3})^3 \), \( w_g = 1 \)\( \deg \le 3 \) par direction
SEG33Gauss-Legendre 3 points\( \deg \le 5 \)
TRI66règle symétrique de degré 4 (Dunavant)\( \deg \le 4 \)
QUA89produit 3×3\( \deg \le 5 \) par direction
QUA99produit 3×3\( \deg \le 5 \) par direction
TET1011règle de Keast de degré 4 (un poids négatif)\( \deg \le 4 \)
PENTA1518produit TRI6 × Gauss 3 points en \( \zeta \)\( \deg \le 4 \), \( \le 5 \) en \( \zeta \)
HEX2027produit 3×3×3\( \deg \le 5 \) par direction
HEX2727produit 3×3×3\( \deg \le 5 \) par direction

La règle "REDUCED" n’a qu’un point, au centre de gravité de l’élément de référence, de poids égal à sa mesure : elle intègre exactement les fonctions affines. D’où viennent ces règles, et ce que chacune intègre exactement, est expliqué avec les éléments finis.

La somme des poids vaut la mesure de l’élément de référence : 2 pour SEG2/SEG3, 1/2 pour TRI3/TRI6, 4 pour QUA4/QUA8/QUA9, 1/6 pour TET4/TET10, 4/3 pour PYRA5, 1/2 pour PENTA6/PENTA15, 8 pour HEX8/HEX20/HEX27. Les tests le vérifient pour tous les types, et vérifient l’exactitude des règles TRI6 et TET10 en intégrant des monômes.

Jacobien et grandeurs physiques

Le jacobien de la transformation \( \chi \) est la matrice des dérivées \[ J_{a,k}(\xi) = \frac{\partial x_a}{\partial \xi_k} = \sum_{i=1}^{n} \mathbf{x}_{i,a}\, \frac{\partial N_i}{\partial \xi_k}(\xi) \quad \text{de taille } d_s \times d_r \] où \( a \) parcourt les \( d_s \) directions physiques et \( k \) les \( d_r = \dim \hat K \) directions de référence.

Cas plein : \( d_s = d_r \). Quand la maille a la dimension des Coords (un TRI3 dans des Coords 2-D), \( J \) est carrée. Son déterminant mesure la dilatation locale du volume, et c’est sa valeur absolue qui entre dans l’intégration : det_jacobian rend \( |\det J| \). Les dérivées physiques des fonctions de forme passent par l’inverse de \( J \) : \[ \frac{\partial N_i}{\partial x_a} = \sum_{k=1}^{d_r} (J^{-1})_{k, a} \, \frac{\partial N_i}{\partial \xi_k} \quad \Longleftrightarrow \quad \nabla_x N_i = J^{-T}\, \nabla_\xi N_i \]

Cas plongé : \( d_s > d_r \). Une zone peut être plongée dans un espace de dimension supérieure : des SEG2 dans des Coords 2-D ou 3-D (un contour, une barre), des TRI3 dans des Coords 3-D (une surface, une coque). \( J \) est alors rectangulaire et n’a plus de déterminant, mais la métrique \[ G(\xi) = J(\xi)^T\, J(\xi) \quad \text{de taille } d_r \times d_r \] est symétrique définie positive tant que la maille n’est pas dégénérée. L’élément de mesure devient \[ d\mu = \sqrt{\det G}\, d\xi \] qui joue le rôle de \( |J| \) dans la quadrature : det_jacobian rend ce \( \sqrt{\det G} \). Le gradient tangent d’un champ (la projection du gradient sur la maille) passe par la pseudo-inverse : \[ \nabla_s N_i = J\, G^{-1}\, \nabla_\xi N_i \] dn_dx rend ces composantes, dans le repère des Coords. Pour \( d_s = d_r \), ces formules redonnent celles du cas plein (\( J G^{-1} = J^{-T} \)), et c’est la seule forme calculée.

Dimensions admises :

type\( d_r \)\( d_s \) admis
SEG2, SEG311, 2, 3
TRI3, TRI6, QUA4, QUA8, QUA922, 3
TET4, TET10, PYRA5, PENTA6, PENTA15, HEX8, HEX20, HEX2733

\( d_s < d_r \) n’a pas de sens (pas de maille 2-D dans un espace 1-D) : la construction le refuse.

Maille dégénérée. dn_dx doit inverser \( G \). Il refuse une maille dont \( \det G \) tombe sous \( 10^{-12} \) fois l’échelle de \( G \) (son plus grand terme, à la puissance \( d_r \)). Le seuil est relatif : une maille d’un demi-millimètre décrite en mètres a un \( \det G \) de l’ordre de \( 10^{-21} \) sans être dégénérée pour autant, et l’unité de longueur choisie ne change rien au verdict.

Axisymétrie. Sur des Coords axisymétriques, le plan du maillage est le plan méridien \( (r, z) \), et une maille y représente un anneau. det_jacobian reste le \( |J| \) de la section : le facteur \( 2\pi r \) entre dans le poids d’intégration, appliqué par les opérateurs qui intègrent, de sorte que raideur, masse, flux, volumes et efforts intérieurs portent sur l’anneau entier.

Côté Rust

SubFiniteElementSpace détient :

  • le Handle<SubMesh> de sa zone, scellé à la construction (mesh::seal) ;
  • son Interpolation et sa QuadratureRule, la dimension des Coords et leur caractère axisymétrique, lus une fois à la construction (un Coords n’en change pas) ;
  • les tables de référence, en vecteurs plats : gauss_xi, gauss_w, n_at_g, dn_at_g pour la géométrie, et field_n_at_g, field_dn_at_g, field_d2n_at_g pour le champ, vides quand le champ suit la géométrie (toute interpolation de Lagrange) ;
  • la coloration, un OnceLock<Vec<Vec<usize>>> marqué #[serde(skip)].

FiniteElementSpace n’est qu’un Vec<Handle<SubFiniteElementSpace>> : il ne garde pas de Mesh. mesh() en reconstruit un avec la zone de chaque sous-espace, partagée, dans l’ordre, et check_push refuse un sous-espace sur d’autres Coords que le premier.

Les constructeurs sont FiniteElementSpace::lagrange1(&mesh), FiniteElementSpace::new(&mesh, interpolation) (Gauss partout) et FiniteElementSpace::with(&mesh, &[(interpolation, quadrature), …]), un couple par zone ; SubFiniteElementSpace::new(submesh, interpolation, quadrature) construit un sous-espace seul.

#[test]
fn le_constructeur_par_defaut_pose_lagrange1_et_gauss() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let a = Node::create_in(coords.clone(), &[0.0, 0.0])?;
    let b = Node::create_in(coords.clone(), &[2.0, 0.0])?;
    let c = Node::create_in(coords.clone(), &[0.0, 2.0])?;

    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::TRI3));
    mesh.add_cell(&[a.id(), b.id(), c.id()])?;

    let fes = FiniteElementSpace::lagrange1(&mesh)?;
    let sub = fes.get(0)?;
    let s = sub.read();
    assert_eq!(s.gauss_count(), 3);
    // Le triangle (0,0), (2,0), (0,2) a |J| = 4 partout : la transformation
    // est affine, et |J| vaut son aire (2) sur celle du triangle de référence (1/2).
    for g in 0..s.gauss_count() {
        let dj = s.det_jacobian(0, g)?;
        assert!((dj - 4.0).abs() < 1e-12);
    }
    Ok(())
}
#[test]
fn le_constructeur_explicite_choisit_par_sous_maillage() -> Result<()> {
    let (_, mesh, _) = triangle()?;

    let fes =
        FiniteElementSpace::with(&mesh, &[(Interpolation::Lagrange1, QuadratureRule::Gauss)])?;

    assert_eq!(fes.len(), 1);
    Ok(())
}

Les évaluations sont les mêmes qu’en Python, sur le guard du sous-espace ; les tables de référence sont prêtées en &[f64], les grandeurs physiques rendues en Vec<f64> :

#[test]
fn evaluer_les_grandeurs_sur_une_cellule() -> Result<()> {
    let (_, _, fes) = triangle()?;
    let sub = fes.get(0)?;

    let s = sub.read();
    for cell_idx in 0..s.cell_count() {
        for g in 0..s.gauss_count() {
            let n = s.n_at_g(g)?; // N_i(ξ_g)
            let dn = s.dn_at_g(g)?; // ∂N_i/∂ξ_k(ξ_g)
            let jac = s.jacobian(cell_idx, g)?;
            let det_j = s.det_jacobian(cell_idx, g)?;
            let dn_dx = s.dn_dx(cell_idx, g)?;
            // … de quoi écrire la matrice élémentaire …
            let _ = (n, dn, jac, det_j, dn_dx);
        }
    }
    Ok(())
}

Le reste de l’API sert les physiques :

  • La base du champ. shape_count(), field_n_at_g(g), field_dn_at_g(g) et field_d2n_at_g(g) lisent la base qui interpole l’inconnue. Sous Lagrange, elles rendent les tables géométriques ; sous ModelEmbedded, elles lèvent une erreur qui nomme la situation ; field_d2n_at_g n’existe que sous Hermite3.
  • La coloration. coloring(compute) rend la coloration, en la calculant au premier appel par la fermeture fournie : c’est l’assemblage (models::kernel) qui choisit les clés de conflit, ici les nœuds des mailles, et la couche conteneur n’en dépend pas.
  • Les pilotes de models::kernel. Un opérateur ne passe pas par jacobian ou dn_dx, qui allouent leur résultat. Il copie une fois, par zone et par appel, les tables de référence du sous-espace, puis parcourt les mailles en parallèle. Chaque maille reçoit un CellGeom qui lit les coordonnées de ses nœuds une seule fois, au premier besoin, et calcule \( \partial N_i/\partial x_a \) et \( |J|\,w_g \) dans des tampons sur la pile (neuf nombres au plus pour \( J \), \( G \) ou leur inverse). CellGeom::det_j_w est le seul endroit où se décide la mesure, facteur \( 2\pi r \) compris, et CellGeom::field_n_at_g celui où les pentes d’Hermite sont multipliées par \( J \).

Champ (Field/SubField)

pyrucast a deux familles de champs : le champ aux nœuds (NodeField), qui donne une valeur à chaque nœud, et le champ aux points de Gauss (ElementField), qui en donne une à chaque point d’intégration de chaque maille. Les deux se lisent et se calculent de la même façon, et cette page décrit ce qu’elles ont en commun ; les deux pages suivantes donnent ce qui est propre à chacune.

En Python, il n’y a pas de classe Field : tout ce qui suit s’écrit à l’identique sur NodeField, sur ElementField et sur leurs zones (SubNodeField, SubElementField). Côté Rust, ce socle commun est écrit une seule fois, dans deux traits (voir Côté Rust).

Zones, supports et composantes

Un champ est un agrégat de zones. Chaque zone :

  • est posée sur un support : un nuage de nœuds (une zone de maillage POI1) pour un champ aux nœuds, un sous-espace d’espace éléments finis pour un champ aux points de Gauss. zone.support() le rend, et champ.support() rend celui de chaque zone, dans l’ordre ;
  • porte des composantes nommées ("T", "u_x", "E", "sigma_xx"…) : au moins une, chaque nom une seule fois ;
  • range une valeur par composante et par ligne de son support : un nœud, ou un couple (maille, point de Gauss).

Les règles de cohérence

Tout champ respecte deux règles, que chaque opération garde :

  1. Une composante par support. Sur un même support, une composante est portée par une seule zone. Plusieurs zones peuvent partager un support si leurs composantes diffèrent : les matériaux de deux physiques sur un même maillage, par exemple.
  2. Une valeur par nœud. Un nœud que plusieurs zones portent y a la même valeur, pour chaque composante qu’elles ont en commun. Deux zones de maillage qui se touchent partagent leurs nœuds de frontière : le champ y a une valeur, rangée dans chacune. La règle ne concerne que le champ aux nœuds, car deux sous-espaces n’ont aucun point de Gauss en commun.

La première se vérifie à l’entrée. NodeField(maillage, …) refuse un maillage qui liste deux fois la même zone, et ElementField(espace, …) un espace qui liste deux fois le même sous-espace : l’union maillage | maillage garde chaque zone une fois. add_sub refuse une zone qui porterait une composante déjà portée sur son support.

La seconde se vérifie à l’union, et en sortie d’une opération entre deux champs quand c’est nécessaire (plus bas). Seule l’écriture dans une zone peut encore la rompre : champ[i].set_value(…) ne touche pas les autres zones qui portent le même nœud. Sur un champ aux nœuds, champ.check() la vérifie à la demande.

Lire un champ

Les zones d’un même champ n’ont pas forcément les mêmes composantes. L’exemple coupe un carré en deux zones de maillage, gauche et droite, qui partagent la diagonale : la température T est connue sur les deux, le flux q sur celle de droite seulement.

# T sur les deux zones, q sur celle de droite seulement.
t = pyrucast.NodeField(gauche, ["T"])
for noeud, valeur in zip(n[:3], (0.0, 10.0, 20.0)):
    t[0].set_value(noeud, "T", valeur)
tq = pyrucast.NodeField(droite, ["T", "q"])
for noeud, valeur in zip((n[1], n[3], n[2]), (10.0, 30.0, 20.0)):
    tq[0].set_value(noeud, "T", valeur)
tq[0].add_to_component("q", 5.0)
champ = t | tq

print(champ.components())  # ['T', 'q'] : les composantes de toutes les zones
print(champ.min("T"), champ.max("T"))  # 0.0 30.0 : cherchés dans les deux zones
print(champ.min())  # 0.0 : toutes les valeurs, toutes composantes confondues
print(champ.sum("q"))  # 15.0 : les trois nœuds de droite, à 5
print(len(champ["q"]), champ["q"].components())  # 1 ['q'] : la zone sans q disparaît
components()toutes les composantes du champ, dans l’ordre où les zones les présentent
min(c), max(c)la plus petite ou la plus grande valeur de c, cherchée dans toutes les zones qui portent c
min(), max()la même chose sur toutes les valeurs, toutes composantes confondues : un nombre, pas une grandeur physique quand les unités diffèrent
sum(c)la somme des valeurs de c, sans poids, un nœud que plusieurs zones portent compté une fois
champ["c"], filter_components([…])un champ réduit à ces composantes
rename_component(a, b)un champ où a s’appelle b

Une composante qu’aucune zone ne porte lève une erreur.

champ["c"] laisse de côté une zone qui ne porte aucune des composantes demandées. Une zone qui n’en porte pas d’autres est partagée avec le champ d’origine, sans copie : écrire dans l’une modifie l’autre. Une zone dont il faut retirer des composantes est recopiée. rename_component partage de même les zones qui ne portent pas a.

sum(c) ajoute une valeur par nœud : un nœud que deux zones partagent n’a qu’une valeur (deuxième règle), et compte une fois. Ce n’est pas une intégrale, qui pèse chaque valeur par la taille des mailles : c’est pyrucast.measure.integral. Deux autres réductions s’appliquent aux deux familles, et comptent de même un nœud une fois : pyrucast.measure.xtx(champ), la somme des carrés des valeurs, et pyrucast.measure.xty(a, b), la somme des produits des valeurs que a et b portent sur les mêmes supports et sous les mêmes noms (voir Opérateurs sur les champs).

Calculer avec des champs

Par un scalaire

champ + s, champ - s, champ * s, champ / s et champ ** s appliquent l’opération à chaque valeur, de chaque composante, de chaque zone, et rendent un nouveau champ ; le scalaire se place d’un côté ou de l’autre. -champ change le signe de chaque valeur. Il en va de même sur une zone.

plus_chaud = champ + 5.0  # un nouveau champ : chaque valeur + 5
ecart = 100.0 - champ  # le scalaire se place d'un côté ou de l'autre
carre = champ**2.0  # puissance, valeur par valeur
print(plus_chaud.max("T"), ecart.min("T"), carre.max("q"))  # 35.0 70.0 25.0
print((-champ).min("T"), (2.0**champ).max("q"))  # -30.0 32.0

garde = champ
champ += 1.0  # un nouveau champ, rangé sous le même nom
print(champ.max("T"), garde.max("T"))  # 31.0 30.0 : `garde` a l'ancien

champ += 1.0 construit lui aussi un nouveau champ, et le range sous le même nom : une autre variable qui désignait l’ancien le garde tel quel. Pour modifier un champ lui-même, on passe par les opérations par composante.

Aucune valeur n’est protégée : une division par zéro donne inf, la puissance fractionnaire d’un nombre négatif donne nan, comme avec numpy. La forme pow(champ, s, m), avec un modulo, est refusée.

Sur une composante, en place

add_to_component(c, s), sub_to_component, mul_to_component et div_to_component modifient le champ lui-même : seule la composante c change, sur toutes les zones qui la portent. Elles lèvent une erreur si aucune zone ne porte c, et div_to_component refuse une division par zéro.

champ.add_to_component("T", -1.0)  # en place : T seulement, sur ses deux zones
champ.mul_to_component("q", 2.0)
print(champ.max("T"), champ.max("q"))  # 30.0 12.0

Sur une zone, set_uniform(c, v) donne la valeur v à la composante c en chaque nœud ou point de Gauss.

Entre deux champs

a + b, a - b, a * b, a / b et a ** b combinent les valeurs de deux champs de la même famille. Les valeurs s’associent par support et par composante :

  • une composante que les deux champs portent sur un même support se combine valeur à valeur, quelle que soit la zone de ce support qui la range de chaque côté ;
  • une composante qu’un seul des deux porte sur un support passe telle quelle, quelle que soit l’opération : a - b rend b, et non -b, sur une composante que seul b porte. C’est ce qui permet de ne toucher qu’à une composante : u + du, où du ne porte que u_x, laisse u_y tel quel, et u * f, où f ne porte que u_x, ne multiplie que u_x ;
  • le résultat garde les zones de a, dans l’ordre. Une composante que seul b porte rejoint la zone de a sur ce support quand a n’en a qu’une ; sinon elle garde une zone à elle, comme une zone de b dont le support n’existe pas dans a.

Les deux champs n’ont donc besoin ni des mêmes composantes, ni des mêmes zones :

# a porte T et q, b porte T et p, sur la même zone.
a = pyrucast.NodeField(gauche, ["T", "q"])
a[0].add_to_component("T", 10.0)
a[0].add_to_component("q", 1.0)
b = pyrucast.NodeField(gauche, ["T", "p"])
b[0].add_to_component("T", 3.0)
b[0].add_to_component("p", 4.0)

d = a - b  # T se combine ; q et p, d'un seul côté, passent tels quels
print(d.components())  # ['T', 'q', 'p']
print(d.value(n[0], "T"), d.value(n[0], "q"), d.value(n[0], "p"))  # 7.0 1.0 4.0

champ + zone combine zone, selon les mêmes règles, avec les zones du champ posées sur son support, et recopie les autres ; il lève une erreur si aucune ne l’est. Entre deux zones, les supports doivent être les mêmes, sinon c’est une erreur.

Le résultat respecte les règles de cohérence. Une composante peut toutefois ne pas avoir été obtenue de la même façon d’une zone à l’autre : calculée ici, passée telle quelle là. Deux zones de ce genre qui partagent un nœud n’y donneraient pas la même valeur. Dans ce seul cas, le résultat est vérifié, et l’opération lève une erreur si deux zones ne s’accordent pas en un nœud.

Même support veut dire même objet

Deux zones s’associent quand leurs supports sont le même objet, jamais en comparant leurs nœuds. Deux régions qui se touchent, construites chacune sur sa zone de maillage, ont des supports distincts, même là où elles partagent des nœuds : g + dr laisserait chaque zone telle quelle, et un nœud de la frontière vaudrait 1 dans l’une et 2 dans l’autre. L’opération lève donc une erreur, qui renvoie à restrict_like.

Pour additionner deux régions, on les ramène d’abord sur les mêmes supports. restrict(champ, maillage) pose un champ aux nœuds sur les zones de maillage, et deux champs restreints au même maillage ont les mêmes supports ; restrict_like(b, a) pose b sur les supports de a (voir Opérateurs sur les champs). Un nœud que le champ restreint ne portait pas y vaut 0.

# Deux régions qui se touchent par la diagonale, chacune sur sa zone.
g = pyrucast.NodeField(gauche, ["T"])
g[0].add_to_component("T", 1.0)
dr = pyrucast.NodeField(droite, ["T"])
dr[0].add_to_component("T", 2.0)

try:
    g + dr  # deux supports distincts : n1 vaudrait 1 dans une zone, 2 dans l'autre
except RuntimeError as erreur:
    message = str(erreur)
    print(message)  # incoherent NodeField: node 1, component T: 1 ≠ 2: …

carre = gauche | droite
somme = g.restrict(carre) + dr.restrict(carre)  # les mêmes supports des deux côtés
print(somme.value(n[0], "T"), somme.value(n[1], "T"), somme.value(n[3], "T"))
# 1.0 3.0 2.0 : la diagonale reçoit les deux

Pour un champ aux points de Gauss, le support est le sous-espace : deux espaces construits sur le même maillage sont deux supports (voir Champ aux points de Gauss).

Fonctions mathématiques et masques

pyrucast.field réunit les fonctions qui s’appliquent valeur par valeur et rendent un nouveau champ de la même famille : abs, sqrt, exp, log (népérien), log10, cos, sin, tan, cosh, sinh et tanh. Chacune existe aussi en méthode : champ.sqrt() donne pyrucast.field.sqrt(champ). Elles ne protègent rien non plus : log d’un nombre négatif donne nan.

champ >= s, champ > s, champ <= s et champ < s rendent un masque : un champ de mêmes zones et de mêmes composantes, qui vaut 1 où la condition tient et 0 ailleurs (c’est mask, voir Opérateurs sur les champs). == et != ne comparent pas les valeurs : champ == 0.0 vaut False.

import pyrucast as pc

amorti = pc.field.exp(champ * -0.1)  # e^(-0.1 v), valeur par valeur
print(round(amorti.min("T"), 4))  # 0.0498 : e^-3, au nœud le plus chaud
print(pc.field.cos(champ).max("T"))  # 1.0 : cos 0, au nœud à 0
print(champ.sqrt().max("T"))  # 5.477… : chaque fonction est aussi une méthode

chaud = champ >= 15.0  # 1 où la valeur atteint 15, 0 ailleurs
print(chaud.value(n[0], "T"), chaud.value(n[3], "T"))  # 0.0 1.0

L’union |

L’union compose des zones sans toucher aux valeurs ; c’est l’arithmétique qui combine des valeurs. Elle respecte les règles de cohérence, à sa manière pour chaque famille :

  • champ aux nœuds : les zones posées sur un même support sont fondues en une. Un nœud doit avoir la même valeur dans toutes les zones qui le portent, pour chaque composante qu’elles ont en commun, sinon l’union lève une erreur ;
  • champ aux points de Gauss : deux zones posées sur un même sous-espace restent côte à côte si leurs composantes sont différentes ; une même composante portée deux fois sur un sous-espace est refusée.

consolidate() fond, à la demande, les zones qui partagent un support (voir Champ aux nœuds et Champ aux points de Gauss).

Ce qui est mis en cache

Un champ ne garde aucun cache : une zone ne contient que le nom de ses composantes, ses valeurs et une référence à son support. Ce qui ne se calcule qu’une fois vit sur les supports, et c’est ce qui rend les opérations ci-dessus possibles ou rapides :

  • le nuage de nœuds d’une zone de maillage. NodeField(maillage, …), restrict et les opérateurs qui produisent un champ aux nœuds posent leurs zones sur le nuage des nœuds de chaque zone de maillage. Ce nuage est calculé une fois, puis rendu tel quel à chaque demande, tant que la zone de maillage ne change pas. Deux champs construits sur le même maillage reçoivent donc le même support et se combinent nœud à nœud : c’est ce qui fait marcher a - b et restrict(…, carre) dans les exemples ci-dessus ;
  • la table qui donne la ligne d’un nœud dans ce nuage, construite à la première lecture d’un nœud : value(nœud, c), set_value et les opérateurs trouvent la ligne d’un nœud sans parcourir la liste ;
  • les tables de l’espace éléments finis (points et poids de Gauss, fonctions de forme et leurs dérivées) et la coloration de ses mailles, pour un champ aux points de Gauss (voir Champ aux points de Gauss).

Côté Rust

Le socle commun est écrit une seule fois, dans deux traits de src/containers/field.rs. Python n’en voit que le résultat : les méthodes et les opérateurs décrits plus haut, présents sur les quatre classes.

  • SubField, la zone. Un type n’a que cinq choses à fournir : support(), components(), values(), values_mut() et same_support_with(…), une zone neuve, à zéro, sur le même support. Tout le reste en découle : component_index, min, max, sum, xtx, dot, pscal, map_all, add_to_component…, merge_components, select_components, rename_component. SubNodeField et SubElementField l’implémentent. SubNodeField fournit en plus row_points(), le nœud de chaque ligne, lu sur place dans le support : c’est ce qui permet de compter un nœud une fois (la première zone qui le porte le compte, les suivantes le sautent) et de vérifier la deuxième règle. Une zone aux points de Gauss garde la réponse par défaut, None.
  • Field, l’agrégat. Il est implémenté d’office pour tout Aggregate dont la zone est un SubField : NodeField et ElementField le reçoivent sans une ligne de code : components, min, max, sum, map_subs, combine_scalar, map_all, merge_field, merge_subfield, dot_field, pscal_field, filter_components, rename_component. Les réductions comptent une fois un nœud que plusieurs zones portent ; merge_field, merge_subfield, dot_field et pscal_field associent les valeurs par support et par composante.
  • Règles de cohérence. add_sub refuse une zone qui porterait une composante déjà portée sur son support : c’est le crochet check_add, qu’une union ne passe pas, puisque son finalize vérifie ou fond le résultat entier. Les opérateurs qui empilent des contributions partielles avant de les additionner nœud à nœud (divergence, forces internes et externes, sum_to_nodes) ne vérifient pas la deuxième règle en chemin : ce ne sont pas encore des champs.

Le contrat ne dit pas ce qu’est une ligne — un nœud pour SubNodeField, un couple (maille, point de Gauss) pour SubElementField. Il fixe seulement la disposition des valeurs dans un tableau plat, où la composante varie le plus vite :

   zone aux composantes [c0, c1]
   values = [ ligne0.c0, ligne0.c1, ligne1.c0, ligne1.c1, … ]
#[test]
fn le_contrat_des_champs_s_ecrit_une_fois() -> Result<()> {
    let coords = Handle::new(Coords::new(1)?);
    let a = Node::create_in(coords.clone(), &[0.0])?;
    let b = Node::create_in(coords, &[1.0])?;
    let support = mesh::poi1_from_nodes(&[a.clone(), b.clone()])?;

    let t = NodeField::new(&support, vec!["T".into()])?;
    t.add_to_component("T", 2.0)?; // Field : en place, sur chaque zone qui porte T
    let tq = NodeField::new(&support, vec!["T".into(), "q".into()])?;
    tq.add_to_component("T", 3.0)?;

    // Les opérateurs rendent un `Result` : combiner deux champs peut échouer.
    let s = (&t + &tq)?; // même support : T se combine, q passe tel quel
    assert_eq!(s.value(a.id(), "T")?, 5.0);
    assert_eq!((2.0 * &t)?.value(b.id(), "T")?, 4.0);

    // Rust n'a pas d'opérateur de puissance : on passe par les primitives.
    let carre = t.combine_scalar(f64::powf, 2.0)?;
    let puissance = t.merge_field(&tq, f64::powf)?; // 2³
    assert_eq!(carre.value(a.id(), "T")?, 4.0);
    assert_eq!(puissance.value(a.id(), "T")?, 8.0);

    // Une zone se lit par le trait `SubField`.
    let zone = t.get(0)?;
    assert_eq!(zone.read().component_index("T"), Some(0));
    assert_eq!(SubField::max(&*zone.read(), Some("T"))?, 2.0);
    Ok(())
}
  • Opérateurs. Trois macros écrivent + - * / : impl_subfield_scalar_ops! (une zone et un scalaire, d’un côté ou de l’autre), impl_subfield_field_ops! (deux zones, par merge_components) et impl_field_ops! (deux agrégats par merge_field, un agrégat et un scalaire par combine_scalar). Combiner peut échouer, sauf une zone avec un scalaire : ces opérateurs rendent un Result. Une zone prise par valeur est modifiée sur place, sans copie ; prise par référence, elle est d’abord copiée.
  • Puissance. Rust n’a pas d’opérateur ** : combine_scalar(f64::powf, s) et merge_field(&autre, f64::powf) font ce que fait ** en Python.
  • Support. same_support compare les Handle par identité (Handle::same_object).
  • Mêmes composantes exigées. check_same_components refuse deux zones dont les supports ou les composantes diffèrent. L’interpolation d’une Evolution l’appelle avant merge_components : deux valeurs tabulées d’un même champ doivent porter les mêmes composantes.
  • Lecture par rang. Un noyau de calcul ne cherche pas une composante par son nom à chaque point : resolve_components traduit, une fois par zone, une liste de noms en rangs dans la zone, et resolve_optional_components marque ABSENT_COMPONENT les noms que la zone ne porte pas.
  • Fonctions mathématiques. Les traits MapValues et Pscal donnent un nom commun aux quatre types (zone ou agrégat, nœuds ou points de Gauss) : ops::field::cos, ops::field::psca… sont écrits une seule fois.
  • Vue de lecture. Field::view prend un verrou de lecture par zone et le garde le temps de la vue. Les opérateurs qui lisent beaucoup (gradient, deformation, divergence, restrict, integral, le tracé…) lisent ainsi les valeurs sur place, sans copie ; d’autres lectures peuvent avoir lieu en même temps, une écriture attend la fin de la vue.
  • Parallélisme. min, max, sum, xtx, map_all… parcourent les valeurs en parallèle. min et max donnent le même résultat quel que soit le nombre de threads ; sum, xtx et dot peuvent différer au dernier chiffre.

Champ aux nœuds (NodeField/SubNodeField)

Un champ aux nœuds porte des valeurs par nœud : une température, un déplacement, des forces nodales, les positions des nœuds. Là où un champ aux points de Gauss donne une valeur à chaque point d’intégration de chaque maille, celui-ci en donne une à chaque nœud, que les mailles voisines partagent. C’est un agrégat de zones :

  • une zone (SubNodeField) range les valeurs des nœuds d’un nuage de nœuds, une zone de maillage POI1 ;
  • un champ (NodeField) est une liste de zones, qui n’ont pas forcément les mêmes composantes : un champ multiphysique porte T sur tout le domaine et ux, uy sur la partie solide seulement.

Les règles communes aux deux familles de champs (composantes, règles de cohérence, statistiques, arithmétique) sont décrites dans Champ ; cette page en donne ce qui est propre aux nœuds.

Créer et lire un champ

pyrucast.NodeField(maillage, ["T"]) crée une zone par zone du maillage, avec ces composantes, toutes les valeurs à zéro ; sur une seule zone de maillage (SubMesh), le champ a une zone. NodeField.with_components_per_submesh(maillage, [["T", "ux"], ["T"]]) donne une liste de composantes par zone. Une liste vide, un nom en double, un nombre de listes qui ne correspond pas aux zones ou un maillage qui liste deux fois la même zone sont refusés.

Le champ se lit nœud par nœud : champ.value(nœud, "T") lit la première zone qui porte ce nœud et cette composante, et lève une erreur si aucune ne les porte. champ.values(nœuds, "T") lit plusieurs nœuds d’un coup et rend leurs valeurs dans le même ordre ; nœuds est une liste de Node, ou une zone ou un maillage dont la connectivité est lue dans l’ordre (pour une zone POI1, ses points). Un nœud d’un autre Coords est refusé.

On écrit dans une zone : champ[i] ou champ.unit() la rendent, et ce sont des vues (écrire dans la zone, c’est écrire dans le champ). Dans une zone, un nœud se désigne par lui-même (zone[nœud, "T"], set_value) ou par sa ligne, son rang dans le support de la zone (get(ligne, rang), set, node_values). Les opérations par composante, comme add_to_component, s’appliquent au champ entier, en place, dans toutes les zones qui portent la composante.

import pyrucast

# Un carré coupé en deux triangles, deux zones de maillage qui partagent la
# diagonale a–d.
c = pyrucast.Coords(2)
a, b, d, e = (c.add_node(p) for p in ([0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]))
bas = pyrucast.Mesh(c, "TRI3")
bas.unit().add_cell([a, b, d])
haut = pyrucast.Mesh(c, "TRI3")
haut.unit().add_cell([a, d, e])
carre = bas | haut

# Une zone par zone de maillage, toutes les valeurs à zéro.
T = pyrucast.NodeField(carre, ["T"])
print(len(T), T.node_count)  # 2 4 — a et d sont rangés dans les deux zones

# En place, dans toutes les zones qui portent T ; puis dans une zone seulement.
T.add_to_component("T", 20.0)
T[1].set_value(e, "T", 25.0)  # e n'appartient qu'à la zone du haut

# Lire par le champ : la première zone qui porte le nœud répond.
print(T.value(e, "T"))  # 25.0
print(T.values([a, b, d, e], "T"))  # [20.0, 20.0, 20.0, 25.0]
print(T.sum("T"))  # 85.0 — a et d ne comptent qu'une fois

# Une zone est une vue : écrire dans zone, c'est écrire dans T.
zone = T[0]
print(zone)  # SubNodeField: 3 node(s), 1 component(s) [T]
print(zone[d, "T"], zone.get(2, 0))  # 20.0 20.0 — d est la ligne 2 de la zone

# Écrire un nœud partagé dans une seule zone rompt la deuxième règle.
zone[d, "T"] = 30.0
try:
    T.check()
except RuntimeError as err:
    print(err)  # incoherent NodeField: node 2, component T: 30 ≠ 20
T[1][d, "T"] = 30.0
T.check()  # les deux zones s'accordent de nouveau

# Une liste de composantes par zone.
mixte = pyrucast.NodeField.with_components_per_submesh(carre, [["T", "ux"], ["T"]])
print(mixte.components())  # ['T', 'ux']
NodeField
len(champ), champ[i], for zone in champ, unit(), |, add_subl’interface des agrégats
support()le support de chaque zone, dans l’ordre, partagé
components()l’union des composantes des zones
value(nœud, "c"), values(nœuds, "c")une valeur, ou une liste dans l’ordre des nœuds
node_countle nombre de nœuds distincts, toutes zones confondues
min(c), max(c), sum(c)sur toutes les zones qui portent c ; sum compte une fois un nœud partagé
champ["c"], filter_components([…]), rename_component(a, b)un champ réduit à ces composantes, ou une composante renommée
add_to_component(c, s), mul_to_component(c, s)…une opération sur une composante, en place
check()vérifie que les zones s’accordent sur les nœuds qu’elles partagent
consolidate()fond les zones posées sur un même support (voir Combiner)
plot(…)le tracé, voir Visualisation
SubNodeField (zone)
support()son nuage de nœuds, partagé
node_count, component_countses deux dimensions
value(nœud, "c"), zone[nœud, "c"]une valeur, par nœud et nom de composante
set_value(nœud, "c", v), zone[nœud, "c"] = vécrire une valeur
get(ligne, rang), set(ligne, rang, v)par ligne et rang de composante
get_by_node(nœud, rang), set_by_node(nœud, rang, v)par nœud et rang de composante
node_values(ligne)toutes les composantes d’une ligne, dans l’ordre
set_uniform("c", v)la composante c à v en tout nœud
components(), component_index("c")les composantes, le rang de l’une

Le support : un nuage de nœuds

Chaque zone est posée sur une zone de maillage POI1 : une liste de nœuds, un par maille. La ligne i de la zone est le i-ème nœud de ce support ; le champ ne range que ses valeurs et lit ses nœuds dans le support.

  • Sur une zone POI1, le champ prend cette zone pour support, telle quelle. Il la refuse si elle liste deux fois un même nœud, puisqu’il range une ligne par nœud ; pyrucast.mesh.to_poi1 en donne les nœuds distincts.
  • Sur une zone d’un autre type (TRI3, HEX8…), il prend son nuage de nœuds : la zone POI1 de ses nœuds distincts, dans leur ordre de première apparition, celle que rend pyrucast.mesh.to_poi1. La zone de maillage le calcule une fois et le garde en cache : deux champs construits sur le même maillage reçoivent le même support.

Le support est scellé, puisque le champ range ses lignes dans l’ordre de ses nœuds. La zone TRI3 d’où vient le nuage, elle, reste modifiable. Si on lui ajoute une maille, elle oublie son nuage : les champs déjà posés restent valides sur l’ancien, et un champ construit ensuite se pose sur un nouveau nuage. Les deux ne se combinent plus nœud à nœud ; pyrucast.node_field.restrict(champ, maillage) porte l’ancien sur le nouveau (voir Maillage).

champ.support() rend le support de chaque zone, dans l’ordre, et zone.support() celui d’une zone. C’est le même objet, pas une copie : un champ construit dessus se combine nœud à nœud avec le premier. Le champ garde son support en vie, mais pas la zone de maillage dont il a été tiré : il n’a plus besoin d’elle.

# Posé sur une zone TRI3, le champ prend son nuage de nœuds : une zone POI1,
# scellée. La zone TRI3 elle-même reste modifiable.
s = T.support()
print(s[0].element_type, s[0].cell_count)  # POI1 3
print(s[0].is_sealed, carre[0].is_sealed)  # True False

# Ce nuage est celui que rend to_poi1, le même objet : l'union ne garde
# que deux zones, pas quatre.
print(len(s | pyrucast.mesh.to_poi1(carre)))  # 2

# Une zone POI1 est elle-même le support.
points = pyrucast.mesh.poi1_from_nodes([b, d])
F = pyrucast.NodeField(points, ["F"])
print(len(F.support() | points), points[0].is_sealed)  # 1 True

Nœuds partagés entre zones

Deux zones de maillage qui se touchent partagent leurs nœuds de frontière, et un champ posé sur elles range ces nœuds dans chaque zone, comme a et d dans le premier exemple. La deuxième règle de cohérence veut qu’un tel nœud ait la même valeur dans toutes les zones qui le portent, pour chaque composante qu’elles ont en commun. Le champ s’appuie sur elle :

  • value et values lisent la première zone qui porte le nœud ;
  • sum, node_count, pyrucast.measure.xtx et xty comptent un nœud partagé une fois ;
  • add_to_component et les autres opérations par composante modifient toutes les zones à la fois, et gardent donc la règle.

L’union et les opérations entre deux champs la vérifient quand c’est nécessaire. Écrire dans une zone, en revanche, ne touche pas les autres : T[0][d, "T"] = 30.0 laisse l’ancienne valeur de d dans la zone du haut. champ.check() compare alors les valeurs exactement et lève une erreur au premier désaccord.

Combiner des champs

Les opérations par un scalaire et entre deux champs suivent les règles de Champ : les valeurs s’associent par support et par composante, et deux zones s’associent quand leurs supports sont le même objet.

L’union a | b réunit les zones sans toucher aux valeurs :

  • les zones posées sur un même support sont fondues en une, qui porte l’union de leurs composantes ; une composante portée des deux côtés doit y avoir les mêmes valeurs ;
  • les zones posées sur des supports distincts restent côte à côte, et doivent s’accorder sur les nœuds qu’elles partagent.

Un désaccord lève une erreur. pyrucast.node_field.merge(a, b) est cette même union. add_sub ajoute une zone telle quelle, sans rien fondre, et refuse une zone qui porterait une composante déjà portée sur son support ; consolidate() fond ensuite les zones qui partagent un support, après la même vérification.

# Sur un même support, l'union fond les zones en une.
q = pyrucast.NodeField(carre, ["q"])
tout = T | q
print([z.components() for z in tout])  # [['T', 'q'], ['T', 'q']]

# Deux supports distincts restent côte à côte, mais doivent s'accorder sur
# les nœuds qu'ils partagent.
coin = pyrucast.NodeField(pyrucast.mesh.poi1_from_nodes([d]), ["T"])
try:
    T | coin
except RuntimeError as err:
    print(err)  # incoherent NodeField: node 2, component T: 30 ≠ 0
coin.unit()[d, "T"] = 30.0
print(len(T | coin))  # 3

# add_sub ajoute une zone telle quelle ; consolidate fond celles qui
# partagent un support.
w = pyrucast.NodeField(carre, ["T"])
w.add_sub(q[0])
print(len(w), len(w.consolidate()))  # 3 2

D’où viennent ces champs, et où ils vont

Les opérateurs qui produisent un champ aux nœuds sont rangés dans pyrucast.node_field (détail dans Opérateurs sur les champs et Assemblage) :

FamilleOpérateurs
géométriepositions(maillage), les coordonnées des nœuds, une composante par axe
d’un champ aux points de Gaussdivergence, sum_to_nodes
assemblageinternal_forces, external_forces
d’un champ aux nœudsrestrict, restrict_like, project, merge, consolidate, mask

La résolution en produit un aussi : pyrucast.solver.solve(K, f) pose sa solution sur les supports des blocs de la matrice, ceux que restrict(…, maillage) donne sur le même maillage (voir Solveur).

Ces champs sont ensuite consommés :

  • portés aux points de Gauss : interp_to_gauss, gradient, deformation, beam_deformation, shell_deformation, strain (dans pyrucast.element_field) ;
  • comme second membre de solve, où un degré de liberté qu’aucune zone ne porte vaut zéro ;
  • pour déplacer les nœuds : pyrucast.coords.set, displace, displace_from, qui traitent une fois un nœud partagé ;
  • par pyrucast.model.update_contact, les réductions de pyrucast.measure (integral, xtx, xty), l’export (pyrucast.export.to_arrays(…, node_fields=[…]), export_vtk) et le tracé.

Ce qui est mis en cache

Un champ aux nœuds ne garde aucun cache : une zone ne contient que ses valeurs, les noms de ses composantes et son support. Il s’appuie sur deux caches des zones de maillage (détaillés dans Maillage) :

  • le nuage de nœuds, gardé par la zone de maillage d’origine, calculé à la première demande puis rendu tel quel. C’est lui qui donne le même support à NodeField(maillage, …), à restrict(…, maillage), à la solution de solve et aux sorties de divergence et de sum_to_nodes sur ce maillage : ils se combinent nœud à nœud. Il épargne aussi le dédoublonnage des nœuds de la zone à chaque nouveau champ ;
  • l’index des nœuds, gardé par le support : la table « nœud → ligne », construite à la première lecture par nœud (value, set_value, values, les opérateurs qui lisent le champ nœud par nœud). Elle sert ensuite à tous les champs posés sur ce support : trouver la ligne d’un nœud ne parcourt plus la liste.

Le support étant scellé, son index ne change plus. Le nuage, lui, est oublié dès que la zone de maillage d’origine change.

Visualisation

champ.plot(component="T") trace un nuage de points colorés : le support POI1 n’a pas de mailles, aucune surface ne s’en déduit. Pour colorer les mailles, on passe le champ au maillage : maillage.plot(field=champ, component="T"). Voir Visualisation.

Sauvegarde

pyrucast.save écrit le champ avec son support et son Coords. Des champs sauvegardés dans un même fichier partagent leur support à la relecture et se combinent nœud à nœud. Le nuage mis en cache n’est pas écrit : un champ construit sur le maillage relu se pose sur un nouveau nuage, et restrict(champ, maillage) y ramène un champ relu. Les nœuds relus appartiennent au Coords relu : value refuse un Node du script qui a écrit le fichier, et on lit avec le nœud relu, o["maillage"].coords().acquire(id).

Côté Rust

SubNodeField porte le Handle<SubMesh> de son support, ses noms de composantes et un Vec<f64> à plat, rangé ligne → composante :

values[ligne * component_count + c]

Les composantes d’un nœud sont donc contiguës : node_values(ligne) en rend la tranche sans copie.

Le Handle<SubMesh> du support est le compagnon POI1 lui-même, sans objet intermédiaire. SubMesh::to_poi1 garde ce compagnon dans un OnceLock et en rend un clone du Handle ; le champ garde ce clone. Un Handle étant un Arc<RwLock<SubMesh>>, le champ et la zone d’origine tiennent deux clones du même Arc, et le champ ne référence pas la zone d’origine.

NodeField est l’agrégat de Handle<SubNodeField>. Son check_push refuse une zone d’un autre Coords, son check_add une composante déjà portée sur le support de la zone (première règle), et son finalize, appelé à la fin de chaque union, est ops::node_field::consolidate.

#[test]
fn un_champ_aux_noeuds_s_ecrit_par_zone_et_se_lit_par_agregat() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let n: Vec<Node> = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]
        .iter()
        .map(|p| Node::create_in(coords.clone(), p))
        .collect::<Result<_>>()?;
    let mut tri = Mesh::from_submesh(SubMesh::new(coords, ElementType::TRI3));
    tri.add_cell(&[n[0].id(), n[1].id(), n[2].id()])?;

    // Une zone par zone de maillage, posée sur son nuage de nœuds : le
    // `Handle` même que `to_poi1` garde en cache, sans objet intermédiaire.
    let u = NodeField::new(&tri, vec!["ux".into(), "uy".into()])?;
    let zone = u.get(0)?;
    let nuage = tri.get(0)?.read().to_poi1()?;
    assert!(zone.read().support().same_object(&nuage));

    // Écriture par la zone, lecture par le champ.
    zone.write().set_value(n[1].id(), "ux", 1.5)?;
    assert_eq!(u.value(n[1].id(), "ux")?, 1.5);
    assert_eq!(u.value(n[0].id(), "ux")?, 0.0); // valeur initiale
    u.check()?;
    Ok(())
}
  • Contrat commun. Les noms de composantes, min/max/sum, les opérations par composante et l’arithmétique viennent des traits SubField et Field (src/containers/field.rs), partagés avec le champ aux points de Gauss.
  • Ligne d’un nœud. SubNodeField::index_of passe par SubMesh::node_index(), un OnceLock<HashMap<NodeId, usize>> du support ; index_of_with prend un verrou de lecture déjà tenu, pour une boucle qui cherche beaucoup de nœuds.
  • Lecture par les opérateurs. NodeFieldView tient un verrou de lecture sur chaque zone et sur son support pendant tout un opérateur (gradient, restrict, project, le tracé…), et lit les valeurs en place.
  • Un nœud une fois. first_rows parcourt les zones dans l’ordre et donne chaque nœud une fois, à la première zone qui le porte : c’est ce qu’utilisent node_ids, node_count et le déplacement des nœuds.

Champ aux points de Gauss (ElementField/SubElementField)

Un champ aux points de Gauss porte des valeurs par maille et par point de Gauss : là où un champ aux nœuds donne une valeur à chaque nœud, celui-ci en donne une à chaque point d’intégration de chaque maille. Deux mailles voisines ne partagent donc rien, et le champ peut sauter d’une maille à l’autre. C’est un agrégat de zones :

  • une zone (SubElementField) porte les valeurs d’un sous-espace d’un espace éléments finis, c’est-à-dire d’une zone de maillage munie de sa quadrature ;
  • un champ (ElementField) est une liste de zones, qui n’ont pas forcément les mêmes composantes.

On y range ce qui vit aux points où se calculent les intégrales :

  • les propriétés matériau (module d’Young, coefficient de Poisson, conductivité, masse volumique…) ;
  • les variables internes d’une loi de comportement (déformation plastique, endommagement…), gardées d’un pas de calcul au suivant ;
  • les grandeurs dérivées d’une solution (déformations, contraintes, gradients, flux…).

On en construit rarement à la main : la plupart sortent des opérateurs (material_field, gradient, integrate_behavior…). Les règles communes aux deux familles de champs (composantes, statistiques, arithmétique) sont décrites dans Champ ; cette page en donne ce qui est propre aux points de Gauss.

Créer et lire un champ

pyrucast.ElementField(fes, ["E", "nu"]) crée une zone par sous-espace de fes, avec ces composantes, toutes les valeurs à zéro. ElementField.with_components_per_subspace(fes, [["E", "nu"], ["k"]]) donne une liste de composantes par sous-espace. Une liste vide, un nom en double ou un nombre de listes qui ne correspond pas aux sous-espaces sont refusés.

On lit et on écrit dans une zone : champ[i] ou champ.unit() la rendent, et ce sont des vues (écrire dans la zone, c’est écrire dans le champ). Un point s’y désigne par trois indices : la maille, dans l’ordre des mailles de la zone de maillage ; le point de Gauss dans la maille, dans l’ordre de la quadrature ; la composante, par son nom ou par son rang. Un indice hors bornes ou une composante inconnue lèvent une erreur.

import pyrucast

# Deux triangles et leur espace éléments finis : 3 points de Gauss par TRI3.
c = pyrucast.Coords(2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
d = c.add_node([1.0, 1.0])
e = c.add_node([0.0, 1.0])
mesh = pyrucast.Mesh(c, "TRI3")
mesh.unit().add_cell([a, b, d])
mesh.unit().add_cell([a, d, e])
fes = pyrucast.FiniteElementSpace(mesh)

# Une zone par sous-espace de fes, toutes les valeurs à zéro.
mat = pyrucast.ElementField(fes, ["E", "nu"])
z = mat.unit()  # la zone, une vue : écrire dans z, c'est écrire dans mat
print(z)  # SubElementField: 2 cell(s) × 3 gauss × 2 component(s) [E, nu]

# Écrire : toute la zone, une maille entière, un point.
z.set_uniform("E", 210e9)
z.set_uniform("nu", 0.3)
z.set_cell_uniform(1, "E", 70e9)  # la maille 1, à tous ses points de Gauss
z[1, 2, "nu"] = 0.33  # la maille 1, son point de Gauss 2

# Lire : par nom, par indice de composante, ou un point entier.
print(z.value(0, 0, "E"))  # 210000000000.0
print(z[1, 2, "nu"])  # 0.33
print(z.get(1, 0, 0))  # 70000000000.0
print(z.point_values(1, 2))  # [70000000000.0, 0.33]
print(mat.max("E"), mat.min("E"))  # 210000000000.0 70000000000.0

# Une liste de composantes par sous-espace (fes n'en a qu'un).
cond = pyrucast.ElementField.with_components_per_subspace(fes, [["k"]])
print(cond.components())  # ['k']
ElementField
len(champ), champ[i], for zone in champ, unit(), |l’interface des agrégats
support()le sous-espace de chaque zone, dans l’ordre, partagé
components()l’union des composantes des zones
min(c), max(c), sum(c)sur toutes les zones qui portent c
champ["c"], filter_components([…]), rename_component(a, b)un champ réduit à ces composantes, ou une composante renommée
add_to_component(c, s), mul_to_component(c, s)…une opération sur une composante, en place
consolidate()fond les zones posées sur un même sous-espace (voir Combiner)
plot(…)le tracé, voir Visualisation
SubElementField (zone)
support()son sous-espace, partagé
cell_count, gauss_count, component_countles trois dimensions de la zone
value(maille, g, "c"), zone[maille, g, "c"]une valeur, par nom de composante
get(maille, g, rang)une valeur, par rang de composante
point_values(maille, g)toutes les composantes d’un point, dans l’ordre
set_value, zone[…] = v, setécrire une valeur
set_uniform("c", v)la composante c à v en tout point
set_cell_uniform(maille, "c", v)la composante c à v en tout point d’une maille
components(), component_index("c")les composantes, le rang de l’une

Les positions des points de Gauss ne sont pas dans le champ : leurs coordonnées dans l’élément de référence et leurs poids sont sur l’espace (fes[i].gauss_xi(g), fes[i].gauss_weight(g)), et leur position réelle se calcule à partir des nœuds.

Composantes

Chaque zone a ses propres composantes, au moins une, chacune nommée une seule fois. Au niveau du champ, components() rend leur union, dans l’ordre de première apparition : une composante peut n’exister que sur certaines zones. C’est le cas d’un champ matériau multiphysique, qui porte E et nu sur la zone de l’élasticité et k sur celle de la thermique.

champ["E"] et champ.filter_components(["E", "nu"]) rendent un nouveau champ réduit à ces composantes. Une zone qui n’en porte aucune disparaît ; une zone qui n’en porte pas d’autres est partagée avec le champ d’origine, sans copie, si bien qu’écrire dans l’une modifie l’autre ; une zone dont il faut retirer des composantes est recopiée.

Le support : un espace éléments finis

Chaque zone est posée sur un sous-espace de l’espace éléments finis, qui fixe ses mailles (celles de sa zone de maillage) et son nombre de points de Gauss par maille (celui de sa quadrature). Ces deux dimensions sont lues à la création de la zone et ne changent plus : l’espace a scellé sa zone de maillage, aucune maille ne peut y être ajoutée. Les nœuds, eux, peuvent bouger : les valeurs du champ restent ce qu’elles sont, et les points de Gauss suivent les mailles.

Deux champs se combinent point à point seulement s’ils sont posés sur le même espace, au sens du même objet. Deux appels à FiniteElementSpace(mesh) sur un même maillage créent deux espaces distincts, et leurs champs restent côte à côte au lieu de s’additionner (voir l’exemple ci-dessous). On construit donc un espace une fois et on le passe à tous les opérateurs ; material_field(modele, …) pose ses zones sur les sous-espaces des sous-modèles, ceux-là mêmes.

Un champ rend aussi le sien : champ.support() donne le sous-espace de chaque zone, dans l’ordre (champ.support()[i] est champ[i].support()), et zone.support() celui d’une zone. C’est le même objet, pas une copie : un champ construit dessus se combine point à point avec le premier. Deux zones posées sur un même sous-espace le citent deux fois ; s | s le garde une fois.

Un champ reste sur sa quadrature : aucun opérateur ne transporte des valeurs d’une quadrature à une autre. Pour obtenir un champ sur un autre espace, on l’interpole depuis les nœuds (interp_to_gauss), ou on l’intègre maille par maille vers un espace à un point par maille (cell_integral).

Un champ garde son espace en vie, et l’espace garde son maillage et son Coords : qu’un script ne tienne plus de variable sur l’espace ou le maillage ne gêne pas le champ. Il ne compte pas les nœuds pour autant ; c’est la zone de maillage qui les compte (voir Maillage).

Combiner des champs

Les opérations suivent les règles de Champ :

  • une opération par un scalaire (champ * 1.1, champ + 2.0…) s’applique à toutes les valeurs et rend un nouveau champ ;
  • a + b, a - b, a * b, a / b, a ** b entre deux champs combinent une composante que les deux portent sur un même sous-espace, même quand chaque côté la range dans une autre de ses zones sur ce sous-espace ; une composante ou une zone présente d’un seul côté passe telle quelle ;
  • champ + zone ne touche que les zones du champ posées sur le même sous-espace ;
  • entre deux zones, les sous-espaces doivent être les mêmes, sinon c’est une erreur ;
  • abs, sqrt, exp, cos… s’appliquent valeur par valeur, et champ >= s rend un masque de 0 et de 1 (mask).

L’union a | b compose des zones sans toucher aux valeurs. Elle garde côte à côte deux zones posées sur le même sous-espace si leurs composantes sont disjointes, et refuse qu’une même composante y soit portée deux fois. consolidate() fond en une seule zone celles qui partagent un sous-espace. Contrairement au champ aux nœuds, l’union ne les fond pas d’elle-même.

# Deux champs posés sur le même espace se combinent point à point.
k1 = pyrucast.ElementField(fes, ["k"])
k2 = pyrucast.ElementField(fes, ["k"])
k1.unit().set_uniform("k", 2.0)
k2.unit().set_uniform("k", 0.5)
somme = k1 + k2
print(len(somme), somme.unit().value(0, 0, "k"))  # 1 2.5

# Un second espace, même sur le même maillage, est un autre support : les
# zones restent côte à côte, rien n'est additionné.
autre = pyrucast.ElementField(pyrucast.FiniteElementSpace(mesh), ["k"])
print(len(k1 + autre))  # 2

# L'espace d'un champ, rendu tel quel : un champ posé dessus s'y combine.
print(len(pyrucast.ElementField(k1.support(), ["k"]) + k1))  # 1

# L'union garde côte à côte deux zones aux composantes disjointes ;
# consolidate les fond en une.
tout = k1 | mat
print(len(tout), tout.components())  # 2 ['k', 'E', 'nu']
print(len(tout.consolidate()))  # 1

sum(c) additionne les valeurs de tous les points de Gauss, sans poids : ce n’est pas une intégrale. L’intégrale d’une composante sur chaque maille est cell_integral (exemple de la section suivante).

D’où viennent ces champs, et où ils vont

Les opérateurs qui produisent un champ aux points de Gauss sont rangés dans pyrucast.element_field (détail dans Opérateurs sur les champs, Construction et Comportement) :

FamilleOpérateurs
matériaumaterial_field(modele, [(nom, valeur)…]), material_field_per_sub_model, sub_material_field
d’un champ aux nœudsinterp_to_gauss, gradient, deformation, beam_deformation, shell_deformation, strain
comportementintegrate_behavior, elastic_stress, elastic_strain, thermal_strain, stress_measure, rotate_frame
d’un champ aux points de Gausscell_integral, mask, consolidate

material_field crée une zone par sous-modèle qui consomme du matériau, sur l’espace de ce sous-modèle, et ignore les autres (Dirichlet…). Il refuse une liste à laquelle manque une composante qu’une physique attend. Deux sous-modèles posés sur un même sous-espace qui lisent une même composante — une conduction et une élasticité qui lisent toutes deux rho — partagent une seule zone, puisqu’un sous-espace porte chaque composante dans une seule zone ; avec material_field_per_sub_model, ils doivent alors lui donner la même valeur.

Ces champs sont ensuite consommés par l’assemblage (stiffness, mass, tangent, internal_forces, external_forces, cf. Assemblage), ramenés aux nœuds (divergence, sum_to_nodes), tracés (plot) ou exportés (pyrucast.export.to_arrays(…, element_fields=[…])).

# Le matériau d'un modèle : une zone par sous-modèle qui en consomme.
elasticite = pyrucast.model.elasticity(fes, "plane_stress")
modele = elasticite | pyrucast.model.heat_conduction(fes)
materiau = pyrucast.element_field.material_field(
    modele, [("E", 210e9), ("nu", 0.3), ("k", 45.0)]
)
print([zone.components() for zone in materiau])  # [['E', 'nu'], ['k']]

# Une température T = x aux nœuds, portée aux points de Gauss.
T = pyrucast.NodeField(mesh, ["T"])
for n in (a, b, d, e):
    T[0].set_value(n, "T", n.position()[0])
T_g = pyrucast.element_field.interp_to_gauss(T, fes)
print([T_g.unit().value(0, g, "T") for g in range(3)])  # [0.5, 1.0, 0.5]
grad = pyrucast.element_field.gradient(T, fes)
print(grad.components())  # ['grad_T_x', 'grad_T_y']
print(grad.unit().point_values(0, 0))  # [1.0, 0.0]

# Une somme sur les points de Gauss n'est pas une intégrale : l'intégrale
# par maille se pose sur un espace à un point par maille.
un = pyrucast.ElementField(fes, ["un"]) + 1.0
centres = pyrucast.FiniteElementSpace(mesh, quadrature="REDUCED")
aires = pyrucast.element_field.cell_integral(un, centres)
print(un.sum("un"), aires.sum("un"))  # 6.0 1.0

Ce qui est mis en cache

Un champ aux points de Gauss ne garde aucun cache : il ne contient que ses valeurs, les noms de ses composantes et ses deux dimensions. Ce qui ne se calcule qu’une fois vit sur l’espace éléments finis qui le porte :

  • les tables de référence (coordonnées et poids des points de Gauss, fonctions de forme et leurs dérivées en ces points), calculées à la création de l’espace ;
  • la coloration des mailles : des groupes de mailles qui ne partagent aucun nœud. Elle sert aux opérateurs qui accumulent des contributions de mailles sur les nœuds (l’assemblage d’une matrice, divergence, sum_to_nodes, internal_forces, external_forces) : les mailles d’un même groupe s’écrivent en parallèle sans conflit. Elle est calculée au premier de ces calculs, puis resert à tous les suivants sur ce sous-espace.

C’est une raison de plus de réutiliser un même espace : un second espace sur le même maillage refait ses tables et sa coloration.

Visualisation

champ.plot(component="E") trace le champ sur son propre support : chaque zone retrouve sa zone de maillage par son sous-espace, sans copie. Le rendu reste par maille : dans chaque maille, des valeurs aux nœuds sont ajustées aux valeurs de Gauss par moindres carrés, sans moyenne avec les mailles voisines, si bien que les sauts d’une maille à l’autre (contraintes, flux) restent visibles. smooth règle la subdivision des mailles (0 : une couleur par maille). Voir Visualisation.

Sauvegarde

pyrucast.save écrit le champ avec son espace, son maillage et son Coords. Des champs sauvegardés dans un même fichier partagent toujours leur espace à la relecture et se combinent point à point ; un champ relu ne partage pas son espace avec ceux du script qui l’a écrit, ni avec ceux d’un autre fichier. Le scellement de la zone de maillage est relu avec elle ; la coloration, elle, n’est pas écrite et se recalcule au premier usage.

Côté Rust

SubElementField porte un Handle<SubFiniteElementSpace>, ses noms de composantes, ses deux dimensions et un Vec<f64> à plat, rangé maille → point de Gauss → composante :

values[maille * gauss_count * component_count
       + g * component_count
       + c]

Les composantes d’un point sont donc contiguës : point_values(maille, g) en rend la tranche sans copie, et c’est ce que lit un noyau de calcul. ElementField est l’agrégat de Handle<SubElementField> ; son finalize refuse une composante portée deux fois sur un même sous-espace.

#[test]
fn un_champ_aux_points_de_gauss_porte_le_materiau() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let a = Node::create_in(coords.clone(), &[0.0, 0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0, 0.0])?;
    let c = Node::create_in(coords.clone(), &[0.0, 1.0])?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords, ElementType::TRI3));
    mesh.add_cell(&[a.id(), b.id(), c.id()])?;
    let fes = FiniteElementSpace::lagrange1(&mesh)?;

    // 2-D linear elasticity: two material properties, one zone (one subspace).
    let mat = ElementField::new(&fes, vec!["E".into(), "nu".into()])?;
    {
        let mut z = mat.get(0)?.write(); // la zone (SubElementField) — guard
        z.set_uniform("E", 210e9)?; // module d'Young constant
        z.set_uniform("nu", 0.3)?; // Poisson constant
        assert_eq!(z.value(0, 0, "E")?, 210e9);
    }

    // Components per subspace (multi-material):
    let mat2 = ElementField::with(
        &fes,
        &[vec!["E".into(), "nu".into()]], // une liste par sous-espace
    )?;

    // Statistics and arithmetic at the aggregate level.
    assert_eq!(Field::max(&mat, Some("E"))?, 210e9);
    let scaled = &mat * 1.1; // nouveau champ (référence : préserve `mat`)
    mat.mul_to_component("E", 0.95)?; // en place, seulement "E"
    let _ = (mat2, scaled);
    Ok(())
}
  • Contrat commun. Les noms de composantes, min/max/sum, les opérations par composante et l’arithmétique viennent des traits SubField et Field (src/containers/field.rs), partagés avec le champ aux nœuds.
  • Support. SubField::same_support compare les Handle par identité (same_object) : c’est ce qui fait de deux espaces sur un même maillage deux supports distincts.
  • Durée de vie. Le Handle de l’espace est un Arc : la zone le garde en vie, l’espace garde son SubMesh, qui garde son Coords et compte ses nœuds (cf. Modèle mémoire).
  • Coloration. Un OnceLock sur SubFiniteElementSpace, rempli par coloring(…) et marqué #[serde(skip)].

Modèle physique (Model/SubModel)

Un modèle décrit le problème à résoudre : quelles physiques, sur quelles zones du maillage, et quelles contraintes les tiennent. Il ne porte aucune valeur de matériau : la conductivité, le module d’Young ou la densité d’un chargement arrivent dans un champ matériau au moment de l’assemblage, et un même modèle sert avec d’autres valeurs. C’est un agrégat :

  • un sous-modèle (SubModel) est une physique sur une zone, c’est-à-dire sur un sous-espace d’un espace éléments finis, ou bien une contrainte ;
  • un modèle (Model) est une liste de sous-modèles, de physiques éventuellement différentes : une conduction et une élasticité sur le même maillage, avec leurs appuis.

Le modèle est la troisième couche, posée sur les deux autres :

CoucheObjetsCe qu’elle décrit
GéométrieMesh / SubMeshles nœuds et les mailles
InterpolationFiniteElementSpace / SubFiniteElementSpaceles fonctions de forme et les points de Gauss
PhysiqueModel / SubModelles équations, leurs inconnues, leurs contraintes

Déclarer un modèle

Chaque physique se déclare par une fonction de pyrucast.model, qui reçoit l’espace éléments finis et rend un modèle couvrant tout l’espace : un sous-modèle par sous-espace. Une contrainte reçoit, au lieu d’un espace, les maillages qu’elle relie, et rend un seul sous-modèle, quel que soit leur nombre de zones.

On compose les modèles avec |, qui rend un nouveau modèle : les sous-modèles des deux côtés, dans l’ordre, sans répéter un sous-modèle déjà présent. Model() est un modèle vide ; add_sub et add_subs ajoutent des sous-modèles en place. Tous les sous-modèles d’un modèle vivent dans un même Coords : en composer deux de Coords différents est refusé.

Certains termes agissent sur les inconnues d’une autre physique : les contraintes (dirichlet, mpc, embedded, contact), les échanges (boundary_transfer, interface_transfer), le rayonnement (radiation) et le chargement réparti (flux). Ils reçoivent cette physique, leur cible : elle donne la ligne où écrire leur effet (q pour une contrainte sur T, f_x pour une contrainte sur u_x), et garantit qu’une physique du problème assemble bien cette ligne.

import pyrucast

# Un carré de 2 × 2 quadrangles.
c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
carre = pyrucast.mesh.extrude(pyrucast.mesh.line(a, b, 2), [0.0, 1.0], 2)
fes = pyrucast.FiniteElementSpace(carre)

# Chaque déclaration rend un modèle : un sous-modèle par sous-espace de `fes`.
thermique = pyrucast.model.heat_conduction(fes)
solide = pyrucast.model.elasticity(fes, "plane_stress")
# Une contrainte reçoit le modèle dont elle fige une inconnue : sa cible.
appui = pyrucast.model.dirichlet(pyrucast.mesh.poi1_from_nodes([a]), solide, "u_x")

modele = thermique | solide | appui
print([sub.label() for sub in modele])  # ['HeatConduction', 'Elasticity', 'Dirichlet']
print(modele.primal_vars())  # ['T', 'u_x', 'u_y', 'lambda']
print(modele.dual_vars())  # ['q', 'f_x', 'f_y', 'imposed']
print(modele.dual_of("u_y"))  # f_y

# `modele[i]` est une vue sur un sous-modèle.
sub = modele[1]
print(sub.physics(), sub.material_components())  # ['mechanical'] ['E', 'nu']
print(modele[2].fespace(), modele[2].has_behavior)  # None False
print(len(modele.fespace()))  # 2 : l'appui n'intègre rien

# L'appui a créé son nœud-multiplicateur, à la position du nœud imposé.
m = modele.multiplier_mesh().node(0, 0, 0)
print(m.position(), m == a)  # [0.0, 0.0] False

modele[i] rend une vue sur un sous-modèle. Une vue se lit (variables, natures, sous-espace, matériau attendu) mais ne se modifie pas : on change un modèle en le recomposant.

Model
len(modele), modele[i], modele[i:j], for sub in modele, unit(), |, add_sub, add_subsl’interface des agrégats
primal_vars(), dual_vars()les inconnues et les duales de tous les sous-modèles, dans l’ordre de première apparition
dual_of(variable)la duale associée à une inconnue, ou None
fespace()l’espace sur lequel le modèle intègre : le sous-espace de chaque physique, dans l’ordre ; deux physiques sur un même sous-espace le citent deux fois, une contrainte n’en a pas
filter(nature), exclude(nature)les sous-modèles qui portent, ou ne portent pas, cette nature
multiplier_mesh()les nœuds-multiplicateurs de toutes les contraintes
constraint_rhs([(nœud, g), …]), constraint_rhs_by_index([(i, g), …])les valeurs imposées, sur un modèle qui porte une seule contrainte (voir Assembler et résoudre)
stiffness_matrix, mass_matrix, geometric_matrix, tangent_matrix, material_field, material_field_per_sub_model, strain, integrate_behavior, elastic_stress, elastic_strain, internal_forces, external_forces, update_contactles opérateurs qui prennent un modèle en premier argument, en méthode : modele.stiffness_matrix(materiaux) est matrix.stiffness(modele, materiaux)
dump()le détail de chaque sous-modèle
SubModel (vue)
label()le nom de la physique : "Elasticity", "Dirichlet"…
physics()ses natures
primal_vars(), dual_vars()ses inconnues et ses duales
fespace()son sous-espace, ou None pour une contrainte
material_components()les composantes matériau qu’il demande, ou None
has_behaviorvrai s’il porte une loi de comportement, que integrate_behavior intègre
multiplier_mesh()ses nœuds-multiplicateurs, vide hors contrainte
material_field([(c, v), …])un champ matériau uniforme pour ce seul sous-modèle

Les physiques disponibles

Le tableau donne, pour chaque déclaration, les variables qu’elle apporte au modèle. Les équations, le matériau et les exemples sont dans la page de chaque physique ; ce que chacune sait assembler est résumé dans Détails des physiques.

Déclaration (pyrucast.model.…)InconnuesDualesPage
heat_conduction(fes, symétrie)TqThermique
radiation(fes, cible)TqRayonnement
fick(fes, espèce, symétrie)c_<espèce>j_<espèce>Diffusion
boundary_transfer(fes, cible, variables)les variablescelles de la cibleÉchanges
interface_transfer(côté_a, côté_b, cible, variables, tol)les variablescelles de la cibleÉchanges
flux(fes, cible, duale)aucuneduale ; "n", une pression, écrit dans f_x, f_y(, f_z)Flux imposé
elasticity(fes, cinématique, symétrie, mesure)u_x, u_y(, u_z)f_x, f_y(, f_z)Élasticité, orthotropie
plasticity_perfect, plasticity_isotropic, drucker_prager, ottosen, gurson, creep_norton, creep_blackburn, creep_lemaitre, viscoplasticity_chaboche, viscoplasticity_lemaitre_chaboche (fes, cinématique, mesure)u_x, u_y(, u_z)f_x, f_y(, f_z)Plasticité, lois d’écoulement, fluage
mazars, damage_tc, damage_sic_sic (fes, cinématique, mesure)u_x, u_y(, u_z)f_x, f_y(, f_z)Mazars, lois d’endommagement
truss(fes, mesure)u_x, u_y(, u_z)f_x, f_y(, f_z)Barre
bernoulli(fes), timoshenko(fes)1-D : w, theta ; plan : u_x, u_y, r_z ; espace : u_x…u_z, r_x…r_zf_w, m_theta ; f_x, f_y, m_z ; f_x…f_z, m_x…m_zEuler-Bernoulli, Timoshenko
shell(fes, formulation)u_x…u_z, r_x…r_zf_x…f_z, m_x…m_zCoques
dirichlet(maillage, cible, variable, sens)lambdaimposedDirichlet
mpc(termes, cible, sens)lambdaimposedMulti-points
embedded(immergé, hôte, cible, variables, tol)lambdaimposedBaignage
contact(esclave, maître, cible, variables)lambdaimposedContact

symétrie ("isotropic"), mesure ("linear"), sens ("=") et tol ont une valeur par défaut. Une poutre lit sa configuration (1-D, plan, espace) sur le maillage. Les composantes matériau dépendent de la physique et de ses options : sub.material_components() les donne.

interface_transfer rend un sous-modèle par paire de sous-espaces des deux côtés, pris dans l’ordre. update_contact(modele, déplacement) ne déclare rien : il rend le modèle avec ses contacts appariés de nouveau sur la configuration déplacée (voir Contact).

Inconnues et multiplicateurs

Chaque physique déclare :

  • ses inconnues, ou variables primales (primal_vars()) : les composantes de la solution, et les colonnes de la matrice ;
  • ses duales (dual_vars()) : les composantes du second membre, et les lignes de la matrice. T a pour duale q, u_x a pour duale f_x.

Une inconnue de la matrice est un couple (nœud, nom). Deux physiques qui emploient le même nom au même nœud partagent l’inconnue : c’est ainsi qu’un échange de bord sur T s’ajoute à la conduction, et que deux zones d’élasticité se raccordent sur leurs nœuds communs. Deux noms différents ne se rencontrent jamais : une conduction et une élasticité sur le même maillage forment, dans la même matrice, deux problèmes que rien ne lie.

Une contrainte impose ses relations par multiplicateurs de Lagrange (voir Contraintes). À sa construction, elle crée dans le même Coords un nœud par relation, le nœud-multiplicateur, à la position d’un nœud de la relation : le nœud imposé d’un Dirichlet, le nœud esclave d’un contact. Ce nœud porte l’inconnue lambda, le multiplicateur, dont l’opposé est la réaction, et la duale imposed, la ligne où s’écrit la valeur imposée. Toutes les contraintes emploient ces deux noms sans se mélanger, puisque chacune a ses propres nœuds. multiplier_mesh() rend ces nœuds, en zones POI1.

Assembler et résoudre

matrix.stiffness(modele, materiaux) rend une seule matrice pour tout le modèle : physiques et contraintes, inconnues et multiplicateurs. Les conditions aux limites sont des sous-modèles comme les autres. Le second membre réunit trois sources :

  • les données que porte le modèle, rendues par node_field.external_forces(modele, materiaux) : un chargement réparti (flux, dont la densité phi_<duale> est lue dans le matériau), la partie ambiante d’un échange de bord, le jeu d’un contact. Un modèle qui n’en a pas rend un champ vide ;
  • les valeurs imposées par une contrainte, rendues par contrainte.constraint_rhs([(nœud, g), …]) : on désigne chaque relation par un de ses nœuds, et la valeur g est écrite à son nœud-multiplicateur, dans la composante imposed. Le modèle doit porter une seule contrainte ; constraint_rhs_by_index désigne la relation par son rang (voir Contraintes) ;
  • ce qu’on écrit soi-même : un champ aux nœuds sur les composantes de dual_vars(), comme une force ou un flux ponctuels.

Deux champs posés sur le même maillage s’additionnent avec +, composante par composante. | réunit des champs qui ne se recouvrent pas, comme les valeurs imposées, portées par les nœuds-multiplicateurs.

# Une barre [0, 1] de 4 SEG2 : conduction, source répartie, T imposée à gauche.
c = pyrucast.Coords(dim=1)
o = c.add_node([0.0])
l = c.add_node([1.0])
barre = pyrucast.mesh.line(o, l, 4)
fes = pyrucast.FiniteElementSpace(barre)
conduction = pyrucast.model.heat_conduction(fes)
source = pyrucast.model.flux(fes, conduction, "q")  # densité lue dans phi_q
gauche = pyrucast.model.dirichlet(pyrucast.mesh.poi1_from_nodes([o]), conduction, "T")
modele = conduction | source | gauche

materiaux = pyrucast.element_field.material_field(modele, [("k", 1.0), ("phi_q", 1.0)])
K = pyrucast.matrix.stiffness(modele, materiaux)

# Les données que porte le modèle : ici la source, ∫ phi_q·N dx.
donnees = pyrucast.node_field.external_forces(modele, materiaux)
# Un flux de 1 entrant en x = 1, écrit à la main sur les nœuds de la barre.
ponctuel = pyrucast.NodeField(barre, ["q"])
ponctuel.unit().set_value(l, "q", 1.0)
# La valeur imposée par la contrainte : T = 2 en x = 0.
imposees = gauche.constraint_rhs([(o, 2.0)])

T = pyrucast.solver.solve(K, (donnees + ponctuel) | imposees)
print(T.value(l, "T"))  # 3.5 = 2 + 1 + 1/2
multiplicateur = gauche.multiplier_mesh().node(0, 0, 0)
print(T.value(multiplicateur, "lambda"))  # 2.0 : la réaction de l'appui

Les opérateurs sont détaillés dans Assemblage et Solveur. Des exemples complets à solution analytique sont déroulés dans Dirichlet (Poisson 1-D), Conduction thermique et Mécanique.

Une physique qui n’a pas un genre de matrice n’y contribue pas : matrix.mass sur une conduction avec échange de bord ne prend que la capacité de la conduction.

Nature physique et filtrage

Chaque sous-modèle déclare un ensemble de natures (physics()), qui dit à quelle physique il appartient :

NatureSous-modèles
"mechanical"elasticity, les lois de plasticité et d’endommagement, truss, bernoulli, timoshenko, shell
"thermal"heat_conduction ; radiation
"radiation"radiation, en plus de "thermal"
"diffusion"fick
"constraint"dirichlet, mpc, embedded, contact
"other"aucune physique ; sert, en Rust, à classer un bloc de matrice monté à la main

Un flux et les échanges prennent la nature de leur cible : sur une conduction, ils sont "thermal", sur une diffusion, "diffusion". Une contrainte porte "constraint" et la nature de la physique dont elle fige les inconnues : un Dirichlet sur u_x est ["constraint", "mechanical"]. Une relation qui lie les inconnues de deux physiques différentes ne porte que "constraint" : aucun problème d’une seule physique ne saurait la résoudre.

filter(nature) garde les sous-modèles qui portent cette nature, et exclude(nature) ceux qui ne la portent pas. Les deux se composent, et les sous-modèles sont partagés, pas copiés. Comme une contrainte porte la nature de ce qu’elle fige, filter("mechanical") rend la mécanique avec ses appuis, un problème qui se résout tel quel, et filter("mechanical").exclude("constraint") la mécanique seule :

# L'appui fige un déplacement : il est une contrainte, et mécanique aussi.
print(appui.unit().physics())  # ['constraint', 'mechanical']
mecanique = modele.filter("mechanical")  # l'élasticité et son appui
print([sub.label() for sub in mecanique])  # ['Elasticity', 'Dirichlet']
seule = mecanique.exclude("constraint")  # l'élasticité seule
print(seule.primal_vars())  # ['u_x', 'u_y']

Les natures disent ce qu’un sous-modèle fige, pas ce qu’il est : un contact et un Dirichlet sur les déplacements portent tous deux ["constraint", "mechanical"]. label() nomme la physique ("Dirichlet", "Contact"…) ; c’est par lui que le pas à pas reconnaît le contact.

Chaque bloc d’une matrice assemblée garde les natures du sous-modèle qui l’a produit : une matrice se sélectionne de la même façon (voir Matrice).

Ce qui est vérifié, et quand

À la déclaration :

  • l’espace convient à la physique : une poutre d’Euler-Bernoulli demande un espace "HERMITE3", une poutre de Timoshenko un espace "MODEL_EMBEDDED" ;
  • la variable qu’une contrainte fige est une inconnue de sa cible ;
  • la composition refuse un sous-modèle d’un autre Coords.

À l’assemblage : le champ matériau porte, sur le sous-espace de chaque sous-modèle, les composantes qu’il demande. Le matériau n’est connu qu’à ce moment-là.

Ce qui est mis en cache

Un modèle garde, pour chaque genre de matrice (raideur, masse, raideur géométrique, tangente), le motif de sa matrice : quelles lignes et quelles colonnes portent un terme, et à quelle place de la matrice creuse chaque terme élémentaire vient s’ajouter. Il est calculé au premier assemblage de ce genre, puis repris tel quel : les assemblages suivants ne calculent que les valeurs.

Ce motif ne dépend que des inconnues que relient les sous-modèles, pas du matériau, de la position des nœuds ou de l’état mécanique. Un calcul non linéaire, qui réassemble sa tangente à chaque itération, le réutilise donc à chaque fois. Le calculer représente plus de la moitié du premier assemblage : sur un million de quadrangles en élasticité plane (4 cœurs), le premier assemblage prend 1,9 s, les suivants 0,7 s.

Le motif est oublié quand on ajoute un sous-modèle (add_sub, add_subs). Un nouveau modèle, rendu par |, filter, exclude, une tranche modele[i:j] ou update_contact, part sans motif : dans une boucle, on garde le modèle dans une variable plutôt que de le recomposer à chaque tour.

Sauvegarde

Un modèle se sauvegarde avec tout ce qu’il désigne : ses espaces, leurs maillages, le Coords, les nœuds-multiplicateurs de ses contraintes. Sauvegardés dans un même fichier, un modèle et son maillage restent liés à la lecture : le modèle relu est posé sur le maillage relu. Le motif des matrices n’est pas écrit ; il se recalcule au premier assemblage.

Côté Rust

Model est l’agrégat de Handle<SubModel>. Il garde aussi le motif de chaque genre de matrice, dans matrix_patterns, un tableau de OnceLock<Arc<AssemblyPattern>> à une case par MatrixKind. Son check_push refuse un sous-modèle d’un autre Coords, et son post_push vide les motifs. ops::matrix::assemble_kind demande ses blocs à chaque sous-modèle, puis prend le motif du modèle, construit au premier appel par ops::scatter::build_pattern, et y répartit les valeurs en parallèle.

SubModel est une enum à une variante par physique, chacune enveloppant la struct de src/models/ qui porte ses données. L’enum sert à ranger et à sauvegarder : bincode écrit le rang de la variante, donc une nouvelle variante se place à la fin. Le code commun (l’agrégat, l’assemblage, l’affichage) passe par as_kind(), qui rend la struct de la variante en &dyn SubModelKind.

SubModelKind (src/models/mod.rs) est ce que toute physique déclare : ses variables, ses natures (physics(), un &'static [Physics]), son nom (label()), ses blocs pour chaque genre de matrice, ses termes de forces internes et externes, son affichage. Trois traits la complètent selon la physique, rendus par as_domain, as_constraint et as_behavior (None par défaut) : Domain pour le matériau et les matrices élémentaires, Constraint pour les nœuds-multiplicateurs et les relations, Behavior pour la loi de comportement. Le chapitre Ajouter une physique détaille ces traits. Un bloc de matrice monté à la main reçoit ses natures par SubMatrix::set_physics, que Python n’expose pas.

#[test]
fn un_modele_se_declare_et_s_assemble() -> Result<()> {
    // Une barre [0, 1] d'un seul SEG2.
    let coords = Handle::new(Coords::new(1)?);
    let a = Node::create_in(coords.clone(), &[0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0])?;
    let mut maillage = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    maillage.add_cell(&[a.id(), b.id()])?;
    let fes = FiniteElementSpace::lagrange1(&maillage)?;

    // La conduction, puis l'appui qui fige T en `a` ; `union` est le `|` de
    // Python.
    let conduction = model::heat_conduction(&fes, MaterialSymmetry::Isotropic)?;
    let impose = mesh::poi1_from_nodes(std::slice::from_ref(&a))?;
    let appui = model::dirichlet(&impose, &conduction, "T", RelationSense::Equality)?;
    let modele = conduction.union(&appui)?;

    // Le matériau arrive à l'assemblage ; l'appui n'en demande pas.
    let materiaux = element_field::material_field(&modele, &[("k", 1.0)])?;
    let k = matrix::stiffness(&modele, &materiaux)?;
    assert_eq!(k.n_rows()?, 3); // 2 nœuds + 1 nœud-multiplicateur

    // L'appui fige une température : il est thermique aussi.
    let thermique = modele.filter(Physics::Thermal);
    let seule = thermique.exclude(Physics::Constraint);
    assert_eq!((thermique.len(), seule.len()), (2, 1));
    Ok(())
}

Matrice creuse (Matrix/SubMatrix)

Une matrice est ce que rend un assemblage : pyrucast.matrix.stiffness(modele, materiaux) rend une seule matrice pour tout le modèle, physiques et contraintes comprises, et mass, geometric et tangent font de même (voir Assemblage). Elle est creuse : seuls ses termes non nuls sont gardés. C’est un agrégat :

  • un bloc (SubMatrix) est la part d’un sous-modèle : la conduction d’une zone, ou l’une des deux moitiés d’une contrainte ;
  • une matrice (Matrix) est la liste de ses blocs, plus ses valeurs assemblées : la matrice que lit le solveur.

Lire une matrice

Une ligne est un couple (nœud, duale), une colonne un couple (nœud, inconnue) : la ligne (0, "q") est le flux au nœud 0, la colonne (0, "T") la température au même nœud. row_dofs() et col_dofs() les donnent dans l’ordre de la matrice, chaque nœud par son identifiant.

import pyrucast

# Une barre [0, 1] de deux SEG2 : conduction, T imposée à gauche.
c = pyrucast.Coords(dim=1)
o = c.add_node([0.0])
l = c.add_node([1.0])
barre = pyrucast.mesh.line(o, l, 2)  # crée le nœud du milieu, d'id 2
fes = pyrucast.FiniteElementSpace(barre)
conduction = pyrucast.model.heat_conduction(fes)
appui = pyrucast.model.dirichlet(pyrucast.mesh.poi1_from_nodes([o]), conduction, "T")
modele = conduction | appui
materiaux = pyrucast.element_field.material_field(
    modele, [("k", 1.0), ("rho", 1.0), ("cp", 1.0)]
)

K = pyrucast.matrix.stiffness(modele, materiaux)
print(
    K
)  # Matrix: 3 sub-matrix(es) [#…, #…, #…], 4 row(s) × 4 col(s), symmetric, ~296 B
print(K.row_dofs())  # [(0, 'q'), (2, 'q'), (1, 'q'), (3, 'imposed')]
print(K.col_dofs())  # [(0, 'T'), (2, 'T'), (1, 'T'), (3, 'lambda')]
print(K.get(o, "q", o, "T"))  # 2.0
K.dump()
#               (0,T)   (2,T)   (1,T)  (3,lambda)
#       (0,q)   2.000  -2.000   0.000       1.000
#       (2,q)  -2.000   4.000  -2.000       0.000
#       (1,q)   0.000  -2.000   2.000       0.000
# (3,imposed)   1.000   0.000   0.000       0.000

Les lignes et les colonnes sont rangées dans l’ordre où les blocs les présentent, ou selon la permutation du Coords s’il en porte une. Dans une matrice symétrique, la ligne i et la colonne i se répondent : même nœud, la duale en ligne et son inconnue en colonne (q et T, imposed et lambda). Une matrice peut aussi être rectangulaire : un bloc de contrainte a une ligne par relation et une colonne par nœud que les relations relient.

get rend 0 pour une case que la matrice ne contient pas. entries() rend toutes les valeurs, ligne par ligne, chacune en tuple (nœud, duale, nœud, inconnue, valeur). dump() affiche la matrice en tableau, une lecture à réserver aux petites matrices.

Les blocs

K[i] est une vue sur un bloc. Un bloc a deux supports, les nœuds de ses lignes et ceux de ses colonnes, en zones POI1, et ses noms de lignes et de colonnes. La matrice ci-dessus a trois blocs : la conduction, puis les deux moitiés de l’appui, C (ligne imposed, colonne T) et Cᵀ (ligne q, colonne lambda).

for bloc in K:
    print(bloc.row_dofs(), bloc.col_dofs(), bloc.physics(), bloc.entry_count)
# [(0, 'q'), (2, 'q'), (1, 'q')] [(0, 'T'), (2, 'T'), (1, 'T')] ['thermal'] 0
# [(3, 'imposed')] [(0, 'T')] ['constraint', 'thermal'] 1
# [(0, 'q')] [(3, 'lambda')] ['constraint', 'thermal'] 1

Un bloc est de l’une de deux sortes :

  • calculé : il ne garde aucune valeur, seulement de quoi les calculer, son sous-modèle, son sous-espace et son matériau. Ses valeurs sont calculées à l’assemblage et écrites directement dans la matrice. Les physiques produisent des blocs calculés : la conduction n’a aucune valeur écrite (entry_count) ;
  • écrit : il garde ses valeurs, une par une. Ce sont les blocs des contraintes, et ceux qu’on écrit soi-même.

Les valeurs se lisent donc sur la matrice, pas sur ses blocs : sur un bloc calculé, get et entries lèvent une erreur. entry_count, sur un bloc comme sur la matrice, compte les valeurs écrites dans les blocs.

Pour écrire un bloc soi-même, Matrix.block rend une matrice d’un seul bloc, vide, qu’on remplit par add_entry. Deux valeurs écrites dans la même case s’additionnent. assemble() donne ensuite ses valeurs à la matrice.

Un bloc est scellé dès qu’une matrice qui le contient reçoit ses valeurs : par assemble(), par un assembleur ou par une somme. add_entry est alors refusé, puisque les valeurs de la matrice ont été calculées à partir de ses entrées. On écrit donc un bloc avant d’assembler, comme on remplit une zone de maillage avant qu’un espace ou un champ ne la scelle.

noeuds = barre.to_poi1()  # les trois nœuds de la barre, chacun une fois
B = pyrucast.Matrix.block(noeuds, noeuds, ["q"], ["T"], symmetry="full")
b = B.unit()
b.add_entry(o, "q", o, "T", 1.0)
b.add_entry(l, "q", l, "T", 1.0)
b.add_entry(o, "q", o, "T", 0.5)  # s'ajoute à la précédente
B.assemble()
print(B.get(o, "q", o, "T"))  # 1.5
print(b.memory_bytes)  # 72 : trois valeurs écrites, 24 octets chacune

Les nœuds d’un support doivent être distincts, comme ceux que rend to_poi1 : un support qui répète un nœud est refusé, comme pour un champ aux nœuds. Quand un bloc porte plusieurs noms, ordering dit comment ses lignes se suivent : nœud par nœud ("nodes_then_vars", par défaut) ou nom par nom ("vars_then_nodes"). symmetry vaut "full" ou "none" (par défaut), voir Symétrie.

Assemblée ou non

Une matrice a des valeurs une fois assemblée. Les assembleurs rendent une matrice assemblée, sur la configuration active au moment de l’appel. Ce qui réunit des blocs rend une matrice sans valeurs : |, filter, exclude, une tranche K[i:j]. add_sub et add_subs, qui changent la matrice en place, effacent ses valeurs.

assemble() calcule les valeurs à partir des blocs seuls, sans modèle, sur la configuration active : un bloc calculé refait son calcul, un bloc écrit apporte ses valeurs.

U = K.filter("thermal").exclude("constraint")  # la conduction sans son appui
print(U)  # Matrix: 1 sub-matrix(es) [#…], 3 row(s) × 3 col(s), symmetric, ~0 B
try:
    U.get(o, "q", o, "T")
except RuntimeError as erreur:
    print(
        erreur
    )  # Matrix::get: this matrix carries a computed block that was never assembled…
U.assemble()  # calcule les valeurs, sur la configuration active
print(U.get(o, "q", o, "T"))  # 2.0

Sur une matrice sans valeurs, K * x et solve lèvent une erreur. get et entries lisent les valeurs écrites dans les blocs, et lèvent une erreur dès qu’un bloc est calculé.

Une matrice est assemblée une fois : ses valeurs ne bougent plus, et un second assemble() ne fait rien. Les valeurs d’une autre configuration s’obtiennent en refaisant la matrice.

Calculer avec des matrices

  • 2.0 * K, K * 2.0, K / dt et -K rendent une matrice neuve, assemblée si K l’était, aux valeurs multipliées. Ses blocs sont des copies qui portent le facteur (factor) : K n’est pas touchée. Diviser par zéro lève ZeroDivisionError, par un nombre infini ou nan, ValueError.
  • A + B et A - B additionnent les valeurs telles qu’elles ont été assemblées, sur la réunion des lignes et des colonnes des deux côtés : le résultat est assemblé et se résout tel quel. Rien n’est recalculé, si bien que deux matrices assemblées sur deux configurations différentes s’additionnent telles quelles. Un opérande dont un bloc calculé n’a jamais été assemblé (une sortie de | ou de filter) lève une erreur ; un opérande fait de blocs écrits seulement est assemblé au passage. Chaque côté peut être une matrice ou un bloc.
  • Les deux côtés n’ont pas besoin des mêmes lignes. Dans M / dt + K, l’appui n’entre que dans K, et M ne contribue rien à ses lignes. A + B range d’abord les lignes de A, puis celles que seule B apporte.
  • K | K est K : les mêmes blocs une seule fois, sans valeurs. K + K vaut 2K. On compose une matrice de morceaux distincts avec |, on additionne deux matrices avec +.
  • K * x, où x est un champ aux nœuds, est le produit : x est lu aux colonnes (une valeur absente vaut 0), et le résultat est un champ posé sur les lignes.
  • lump() rend la matrice diagonale dont chaque terme est la somme de sa ligne : la masse concentrée. Elle garde les lignes et les colonnes de la matrice d’origine, avec un bloc diagonal par support de lignes, qui porte ses noms et ses natures : elle se résout et se multiplie comme elle.

Deux matrices ne se multiplient pas entre elles.

M = pyrucast.matrix.mass(modele, materiaux)  # la capacité : l'appui n'y entre pas
dt = 0.1
A = M / dt + K  # assemblée, prête à résoudre
print(A.row_dofs() == K.row_dofs())  # True

print((K + K).get(o, "q", o, "T"))  # 4.0 : chaque opérande compte
une_fois = K | K  # les mêmes blocs, une seule fois, sans valeurs
une_fois.assemble()
print(une_fois.get(o, "q", o, "T"))  # 2.0

# Le produit par un champ lit T aux colonnes et rend q aux lignes.
x = pyrucast.NodeField(barre, ["T"])
x.unit().set_uniform("T", 1.0)
print((K * x).value(l, "q"))  # 0.0 : une température uniforme ne crée aucun flux

Natures physiques

Chaque bloc garde les natures du sous-modèle qui l’a produit (voir Modèle), et K.physics() réunit celles de tous les blocs. filter(nature) garde les blocs qui portent cette nature, exclude(nature) ceux qui ne la portent pas ; les blocs sont partagés, et le résultat est sans valeurs. Un bloc écrit à la main n’a pas de nature : filter ne le garde jamais, exclude le garde toujours.

Symétrie

K.symmetric dit si la matrice assemblée est symétrique. Les valeurs ne sont pas comparées : chaque bloc déclare sa part, et la matrice réunit les déclarations.

  • Un bloc de raideur élastique ou de masse est symétrique à lui seul (is_symmetric).
  • Les blocs C et Cᵀ d’une contrainte ne le sont pas seuls, mais forment ensemble une symétrie : la matrice n’est symétrique que s’ils sont présents tous les deux.
  • Un bloc écrit à la main déclare symmetry="full" ou "none".
print([bloc.is_symmetric for bloc in K])  # [True, False, False]
print(K.symmetric)  # True : C et Cᵀ sont présents tous les deux
print(K[0:2].symmetric)  # False : Cᵀ a été laissé de côté

Le solveur lit la matrice colonne par colonne. Une matrice symétrique s’y prête telle quelle, une autre est d’abord recopiée dans ce sens. Seule une matrice symétrique accepte method="cholesky". Une déclaration fausse n’est pas détectée : déclarer "full" un bloc qui ne l’est pas fait résoudre le système de la matrice transposée.

Résoudre

K.solve(second_membre) est pyrucast.solver.solve(K, second_membre). La matrice porte tout ce qu’il faut : les lignes des relations, leurs coefficients et leur sens. Les options sont décrites dans Solveur.

impose = appui.constraint_rhs([(o, 1.0)])  # T = 1 en x = 0
T = K.solve(impose)  # factorise K, et garde la factorisation dans K
print(T.value(l, "T"))  # 1.0
T = K.solve(2.0 * impose)  # reprend la factorisation : la descente-remontée seule
print(T.value(l, "T"))  # 2.0

Ce qui est mis en cache

Le premier solve sur une matrice la factorise, c’est-à-dire la décompose en un produit de matrices triangulaires, l’étape coûteuse, et garde le résultat dans la matrice. Les solve suivants sur la même matrice le reprennent et ne font que la descente-remontée, bien moins chère : plusieurs chargements, un transitoire à pas de temps constant, des itérations qui ne changent pas la matrice.

La matrice n’a qu’une case pour cela. Son contenu dépend de la voie du solveur (voir Solveur) :

  • une factorisation de la matrice, LU ou Cholesky, quand on résout avec les multiplicateurs sans inégalité, ou par élimination une matrice sans relation ;
  • une condensation, pour l’élimination (relations="eliminate") : la table T qui exprime chaque inconnue éliminée par les autres, la matrice sans ses contraintes, et la factorisation du système réduit K̂ = TᵀKT ; ou, si des inégalités restent, ce système réduit et l’état de l’ensemble actif qui le résout ;
  • l’état de l’ensemble actif, quand la matrice porte des inégalités : les inégalités, le dernier statut convergé (d’où repart la résolution suivante) et ses factorisations LU — celle de la base de Schur et ses colonnes, ou celle du dernier statut.

Le contenu n’est repris que s’il répond à la demande : la même voie, et, pour une factorisation, la même method. Sinon il est recalculé et prend la place.

Ce qui est gardé est oublié quand la matrice change (add_sub, add_subs). Une matrice neuve part sans : 2.0 * K, M / dt + K ou un nouvel assemblage factorisent de nouveau. Dans une boucle, on construit la matrice une fois et on la garde dans une variable. solve(…, cache=False) ne garde rien.

Une matrice rendue par un assembleur partage sa structure, c’est-à-dire quelles cases sont non nulles et l’ordre des lignes et des colonnes, avec le motif que le modèle garde pour ce genre de matrice (voir Modèle) : deux raideurs du même modèle n’ont qu’une structure en mémoire, et chacune ne possède que ses valeurs.

Ce que pèse une matrice

memory_bytes estime les octets occupés. Un bloc compte 24 octets par valeur écrite (deux indices et la valeur), et 0 s’il est calculé. Une matrice compte ses valeurs assemblées et leur structure (un indice de colonne et une valeur par terme non nul, un début par ligne, une clé par ligne et par colonne), plus ce que gardent ses blocs. Une structure partagée entre matrices est comptée dans chacune. L’estimation apparaît dans l’affichage (~296 B plus haut).

La factorisation n’est pas comptée, alors qu’elle est en général bien plus grosse que la matrice : faer, la bibliothèque qui la calcule, ne donne pas la taille de ses facteurs. Pour la mémoire d’un solve, voir Calculs plus gros que la RAM.

Sauvegarde

Une matrice se sauvegarde avec ses blocs, et avec ce que désignent ses blocs calculés : sous-modèle, sous-espace, matériau. Ni ses valeurs assemblées ni sa factorisation ne sont écrites : à la relecture, assemble() recalcule les valeurs depuis les blocs, sur la configuration active. Une somme de matrices assemblées sur deux configurations est donc recalculée sur une seule.

Côté Rust

Matrix est l’agrégat de Handle<SubMatrix>, avec deux membres de plus :

  • assembled, les valeurs assemblées et la numérotation des lignes et des colonnes, None tant que la matrice n’est pas assemblée. Les valeurs sont rangées ligne par ligne (forme CSR) : trois tableaux, le début de chaque ligne, la colonne de chaque terme et sa valeur. Pour une matrice rendue par un assembleur, les deux premiers et la numérotation sont des Arc partagés avec le motif du modèle (AssemblyPattern) ; seules les valeurs lui appartiennent.
  • factorization, ce que garde le solveur, dans un parking_lot::Mutex<Option<Arc<dyn Any + Send + Sync>>>. dyn Any accepte n’importe quel type, si bien que containers ne dépend pas du solveur : cached_factorization::<T>() le rend s’il est de type T, et store_factorization le range. Le Mutex permet de le remplir depuis solve(&Matrix), qui ne reçoit qu’une référence partagée.

check_push refuse un bloc posé sur un autre Coords, et post_push vide assembled et factorization.

Une ligne ou une colonne est une DofKey : un u64 qui porte l’identifiant du nœud dans ses 32 bits hauts, et dans ses 32 bits bas le rang de son nom dans la table de noms de la matrice (dof_vars()). row_dofs() rend la forme lisible, NamedDof = (NodeId, String).

SubMatrix porte :

  • row_support et col_support, deux Handle<SubMesh> POI1 scellés à la construction du bloc. Le bloc ne recopie pas leur liste de nœuds : il la lit en place et trouve le rang d’un nœud par la table node_index du support (voir Maillage). D’où la règle des nœuds distincts : cette table donne le rang parmi les nœuds sans répétition, et les constructeurs (new, computed, from_coo) refusent un support qui répète un nœud ou qui n’est pas un POI1 ;
  • dual_vars, primal_vars et ordering (DofOrdering) : la ligne d’un couple (nœud, nom) est rang_du_nœud * n_noms + rang_du_nom en NodesThenVars, rang_du_nom * n_nœuds + rang_du_nœud en VarsThenNodes ;
  • coo, les valeurs d’un bloc écrit, en liste de triplets (ligne, colonne, valeur) (nalgebra_sparse::CooMatrix), vide pour un bloc calculé ;
  • recipe, Some(ComputedRecipe) pour un bloc calculé : le sous-modèle, les sous-espaces, le matériau, le MatrixKind et les KernelInputs, ce que le calcul lit en plus du matériau (la contrainte pour la raideur géométrique, la déformation et l’état précédent pour la tangente) ;
  • symmetry, physics, factor, et row_sense, le sens (égalité ou inégalité) des lignes imposed, que lit le solveur ;
  • sealed, posé quand une matrice qui contient le bloc reçoit ses valeurs (assemblage ou somme), et lu par add_entry. Il se pose comme celui d’une zone de maillage : la fonction seal lit d’abord, et ne prend le verrou d’écriture que si le bloc n’est pas encore scellé. Il n’est pas sauvegardé : une matrice relue n’a pas de valeurs, et son assemblage scelle de nouveau ses blocs.

Sur un bloc calculé, toute lecture de valeurs (get, iter_entries, to_dmatrix, to_coo, to_csr, to_csc) rend une erreur.

Assemblage. ops::matrix::stiffness, comme mass, geometric et tangent, demande ses blocs à chaque sous-modèle, prend le motif du modèle (construit au premier appel par ops::scatter::build_pattern), calcule les valeurs en parallèle par coloriage des mailles (voir Parallélisme) et les remet à la matrice par set_assembled. Matrix::assemble est défini dans ops::matrix et non avec la matrice : calculer un bloc calculé demande le calcul élémentaire, qui vit dans models, dont containers ne dépend pas. Il construit le motif depuis les blocs à chaque appel.

Facteur. Il s’applique partout où une valeur du bloc est lue ou émise (get, iter_entries, les conversions, l’assemblage), sauf dans local_triplets et local_coo_arrays, les valeurs brutes que chaque lecteur multiplie lui-même. &k * s copie chaque bloc ; k * s, qui prend k, multiplie en place les blocs que personne d’autre ne tient (Handle::is_sole_owner). Les valeurs assemblées sont multipliées aussi, la structure restant partagée : pas de réassemblage. (Σ v) · s n’est pas, au bit près, le Σ (v · s) d’un réassemblage, l’addition des nombres à virgule flottante n’étant pas associative. En Rust, une division par zéro ou par un nombre non fini arrête le programme (panic).

Somme. &a + &b rend un Result<Matrix>. Deux matrices assemblées sur le même motif additionnent leurs valeurs case à case ; sinon, les lignes et les colonnes sont renumérotées sur leur réunion.

#[test]
fn additionner_puis_resoudre() -> Result<()> {
    let (model, materials, rhs) = barre()?;
    let k = matrix::stiffness(&model, &materials)?;
    let m = matrix::mass(&model, &materials)?;
    let dt = 0.1;

    // `/` ne peut pas échouer et rend une `Matrix` ; `+` rend un `Result`,
    // puisqu'un opérande peut ne pas avoir de valeurs.
    let systeme = (&m / dt + &k)?;
    let t = solver::solve(&systeme, &rhs)?;

    assert!(t.node_count() > 0);
    Ok(())
}

Symétrie. Symmetry vaut Full, Half(PairId) ou None. Les deux moitiés d’une paire reçoivent ensemble leur identité par mint_pair, calculée sur leur contenu (nœuds des deux supports, noms, coefficient) : elle ne dépend pas de l’exécution, et deux sauvegardes des mêmes objets donnent les mêmes octets. Deux paires distinctes qui tomberaient sur la même identité seraient rejetées, jamais acceptées à tort. Matrix::symmetric() est vrai si chaque bloc est Full, ou Half avec ses deux membres présents en nombres égaux : une contrainte déclarée deux fois reste symétrique.

Dans une matrice symétrique, lignes et colonnes sont numérotées en un seul parcours, pour que la ligne i et la colonne i se répondent : la ligne k d’un bloc Full fait face à sa colonne k, et une paire Half se croise. Deux incohérences y sont refusées : un même degré de liberté dual associé à deux primaux différents, et une paire dont les membres n’ont pas la même taille. Deux blocs rectangulaires qui sont chacun une tranche de lignes d’une matrice symétrique ne peuvent rien déclarer : la matrice répond false, et le solveur prend le chemin général.

Solveur. csr_arrays() prête les trois tableaux sans copie. La factorisation de faer lit la matrice par colonnes (forme CSC) ; or les tableaux CSR d’une matrice sont exactement les tableaux CSC de sa transposée. Une matrice symétrique est donc passée telle quelle, une autre est transposée une fois. to_csr, to_csc, to_coo et to_dmatrix fabriquent des objets nalgebra neufs.

#[test]
fn lire_une_matrice_assemblee() -> Result<()> {
    let (model, materials, _) = barre()?;
    let k = matrix::stiffness(&model, &materials)?;
    let a: NodeId = k.row_mesh()?.node(0, 0, 0)?.id();
    let x = NodeField::from_submesh(&k.col_mesh()?.get(0)?, vec!["T".into()])?;

    // Une valeur.
    let v: f64 = k.get(a, "q", a, "T")?;

    // Les trois tableaux de la matrice creuse, prêtés sans copie…
    let (debuts, colonnes, valeurs): (&[usize], &[usize], &[f64]) = k.csr_arrays()?;
    assert_eq!(debuts.len(), k.n_rows()? + 1);
    assert_eq!(colonnes.len(), valeurs.len());
    // …ou des objets neufs, de nalgebra et nalgebra-sparse.
    let plein: nalgebra::DMatrix<f64> = k.to_dmatrix()?;
    let csr: nalgebra_sparse::CsrMatrix<f64> = k.to_csr()?;
    let csc: nalgebra_sparse::CscMatrix<f64> = k.to_csc()?;

    // Le produit par un champ : `x` est lu aux colonnes, le résultat est posé
    // sur les lignes.
    let y: NodeField = (&k * &x)?;

    let _ = (v, plein, csr, csc, y);
    Ok(())
}

Un bloc écrit se construit par SubMatrix::new, puis add_entry ; SubMatrix::set_physics, que Python n’expose pas, lui donne des natures, par exemple Physics::Other pour que filter puisse le retrouver.

#[test]
fn ecrire_un_bloc() -> Result<()> {
    let coords = Handle::new(Coords::new(1)?);
    let a = Node::create_in(coords.clone(), &[0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0])?;
    let support = {
        let mut sm = SubMesh::new(coords.clone(), ElementType::POI1);
        sm.add_cell(&[a.id()])?;
        sm.add_cell(&[b.id()])?;
        Handle::new(sm)
    };

    let mut bloc = SubMatrix::new(
        support.clone(),  // support des lignes
        support.clone(),  // support des colonnes
        vec!["q".into()], // variables duales : les lignes
        vec!["T".into()], // variables primales : les colonnes
        DofOrdering::NodesThenVars,
        Symmetry::Full, // symétrique à lui seul
    )
    .unwrap();
    // K = [[2, -1], [-1, 2]]
    bloc.add_entry(a.id(), "q", a.id(), "T", 2.0)?;
    bloc.add_entry(a.id(), "q", b.id(), "T", -1.0)?;
    bloc.add_entry(b.id(), "q", a.id(), "T", -1.0)?;
    bloc.add_entry(b.id(), "q", b.id(), "T", 2.0)?;

    let mut k = Matrix::empty();
    k.add_sub(Handle::new(bloc))?;
    k.assemble()?; // assemble les blocs écrits

    assert_eq!((k.n_rows()?, k.n_cols()?), (2, 2));
    assert!(k.symmetric());
    Ok(())
}

Évolution (Evolution/SubEvolution)

Une évolution associe des valeurs à des abscisses et interpole linéairement entre elles. L’abscisse est souvent le temps, mais pas forcément : une température, une déformation, ou plusieurs variables à la fois. La valeur est un nombre, un champ aux nœuds ou un champ aux points de Gauss.

C’est un agrégat :

  • une SubEvolution est une courbe : la liste de ses abscisses et la liste des valeurs en regard. L’interpoler en x rend une valeur ;
  • une Evolution réunit des SubEvolution, une par zone, comme un NodeField réunit ses zones. L’interpoler interpole chaque courbe et regroupe les résultats : un NodeField ou un ElementField pour des champs, une liste de flottants pour des nombres.

Une courbe

SubEvolution(échantillons) prend une liste de couples (abscisse, valeur). Les échantillons sont triés à la construction ; deux à la même abscisse sont refusés. Entre deux échantillons x₀ ≤ x ≤ x₁, la valeur est v₀ + (v₁ − v₀)·(x − x₀)/(x₁ − x₀) ; sur un échantillon, c’est sa valeur.

import pyrucast

# Une température qui monte de 20 à 120 °C en 10 s, puis reste à 120 °C.
montee = pyrucast.SubEvolution([(0.0, 20.0), (10.0, 120.0), (60.0, 120.0)])
print(montee.interpolate(2.5))  # 45.0
print(montee.interpolate(10.0))  # 120.0 : un échantillon, rendu tel quel
print(montee.abscissas())  # [0.0, 10.0, 60.0]
print(len(montee))  # 3

Hors plage

Une abscisse en dehors de la plage tabulée suit la politique hors plage de l’évolution :

PolitiqueHors plage
"error" (par défaut)lève une erreur
"clamp"rend la valeur de l’extrémité la plus proche
"extrapolate"prolonge le premier ou le dernier segment

La politique se donne à la construction (out_of_range="clamp"), se lit par out_of_range, et s’impose pour un seul appel : interpolate(x, out_of_range="clamp").

try:
    montee.interpolate(70.0)  # politique "error", par défaut
except RuntimeError as erreur:
    print(erreur)  # evolution: abscissa 70 is outside the tabulated range [0, 60]
print(montee.interpolate(70.0, out_of_range="clamp"))  # 120.0
print(montee.interpolate(-1.0, out_of_range="extrapolate"))  # 10.0

bornee = pyrucast.SubEvolution([(0.0, 20.0), (10.0, 120.0)], out_of_range="clamp")
print(bornee.out_of_range, bornee.interpolate(70.0))  # clamp 120.0

Des champs comme valeurs

Evolution([(t₀, champ₀), (t₁, champ₁), …]) prend un champ entier par abscisse, un NodeField ou un ElementField. Elle en fait une courbe par zone : les zones sont celles du premier champ, et chaque champ suivant doit porter une zone sur chacun de ces supports ; une zone qu’il aurait en plus est ignorée. Toutes les valeurs d’une courbe ont le même support et les mêmes composantes.

L’évolution copie les valeurs des champs ; le support, lui, est partagé. Modifier un champ ensuite ne change pas l’évolution, et le champ interpolé est posé sur le même support que les champs d’origine : il se combine avec eux zone à zone.

shared_abscissas() rend les abscisses, communes à toutes les zones, et frames() les champs tabulés, un par abscisse.

c = pyrucast.Coords(1)
noeuds = [c.add_node([float(i)]) for i in range(3)]
support = pyrucast.mesh.poi1_from_nodes(noeuds)
froid = pyrucast.NodeField(support, ["T"])
froid.unit().set_uniform("T", 20.0)
chaud = pyrucast.NodeField(support, ["T"])
chaud.unit().set_uniform("T", 120.0)

chauffe = pyrucast.Evolution([(0.0, froid), (10.0, chaud)])
t = chauffe.interpolate(2.5)  # un NodeField, sur le même support
print(t.value(noeuds[0], "T"))  # 45.0
print(chauffe.shared_abscissas())  # [0.0, 10.0]
print(len(chauffe.frames()))  # 2 : les champs tabulés, un par abscisse

Une abscisse à plusieurs variables

Une abscisse peut être un tuple de deux à quatre nombres : la courbe devient une table. Chaque échantillon est alors ((T, φ), valeur), et interpolate((T, φ)) rend la valeur en ce point.

# Limite d'élasticité σ_y (MPa) selon la température T et la fraction φ :
# deux valeurs de φ mesurées à 20 °C, trois à 500 °C.
sigma_y = pyrucast.SubEvolution(
    [
        ((20.0, 0.0), 300.0),
        ((20.0, 1.0), 450.0),
        ((500.0, 0.0), 200.0),
        ((500.0, 0.5), 260.0),
        ((500.0, 1.0), 320.0),
    ],
    abscissa_type=("T", "phi"),
    ordinate_type="sigma_y",
)
print(sigma_y.interpolate((300.0, 0.4)))  # 294.666…

La table se lit variable après variable. Les échantillons sont rangés par leur première variable : ici, un groupe à 20 °C (φ = 0 et 1), un autre à 500 °C (φ = 0, 0,5 et 1). Chaque groupe est une courbe le long de la variable suivante. En (300, 0,4) :

   φ
   1  ● 450                               ● 320
      │                                   │
 0,5  │                                   ● 260
 0,4  ◆ 360 ─────────────── ★ ─────────── ◆ 248
      │                   294,7           │
   0  ● 300                               ● 200
      └────────────────────┬──────────────┴───── T
      20                  300            500

  ●  échantillons de la table
  ◆  étape 1 : chaque groupe est lu le long de φ, en φ = 0,4
  ★  étape 2 : les deux résultats sont pesés le long de T, en T = 300
  1. On cherche les deux groupes qui encadrent T = 300 : 20 °C et 500 °C.
  2. Chacun est interpolé le long de φ en 0,4 : entre 300 et 450 à 20 °C, ce qui donne 360 ; entre 200 et 260 à 500 °C (φ de 0 à 0,5), ce qui donne 248.
  3. On interpole entre ces deux résultats le long de T : t = (300 − 20)/(500 − 20) ≈ 0,583, et 360 + 0,583 × (248 − 360) ≈ 294,7.

Pour deux variables, avec T_a ≤ T ≤ T_b les groupes encadrants et f_a, f_b leurs courbes en φ :

\[ f(T, \varphi) = (1-t)\, f_a(\varphi) + t\, f_b(\varphi), \qquad t = \frac{T - T_a}{T_b - T_a}. \]

Avec trois ou quatre variables, chaque groupe se lit de la même façon le long de la variable suivante. Ce que cela donne :

  • les groupes n’ont pas à partager leurs abscisses : la table ci-dessus mesure deux valeurs de φ à 20 °C et trois à 500 °C ;
  • sur une grille complète, le résultat est l’interpolation multilinéaire (bilinéaire pour deux variables). Une fonction linéaire en chaque variable séparément, comme a + bT + cφ + dTφ, est retrouvée exactement, grille complète ou non ;
  • un groupe où la variable suivante ne prend qu’une valeur est constant le long d’elle : une propriété mesurée pour une seule valeur de φ à une température donnée ne dépend pas de φ à cette température ;
  • l’ordre des variables compte : la première range les groupes, et c’est le long d’elle que se fait le dernier mélange. Sur une table dont les groupes ne partagent pas leurs abscisses, échanger l’ordre des variables change le résultat ;
  • hors de la plage d’une variable, la politique hors plage s’applique à cette variable, dans le groupe où elle est lue.

abscissas() rend les abscisses triées : des nombres pour une courbe, des tuples pour une table, rangés par première variable puis par la suivante.

Types d’abscisse et d’ordonnée

Une courbe peut porter le nom de ce que mesurent ses axes :

  • abscissa_type, le nom de l’abscisse ("T", "t"), un tuple de noms pour une table (("T", "phi")), un par variable ;
  • ordinate_type, le nom de la valeur ("E"), pour une courbe de nombres seulement : un champ a déjà ses composantes, et le donner à une évolution de champs est une erreur.

Ils se donnent à la construction et se lisent par abscissa_type() et ordinate_type() ; ceux d’une Evolution sont ceux de sa première courbe. Ils étiquettent les tracés et relient une courbe à un champ (section suivante).

young = pyrucast.SubEvolution(
    [(20.0, 210e3), (500.0, 170e3)], abscissa_type="T", ordinate_type="E"
)
print(young.abscissa_type(), young.ordinate_type())  # T E
print(sigma_y.abscissa_type())  # ('T', 'phi')

Appliquer une courbe à un champ

Une courbe de nombres s’applique à un champ comme cos : interpolate reçoit un champ au lieu d’une abscisse, et rend un champ sur le même support, dont chaque nœud ou point de Gauss est la courbe évaluée en ses propres valeurs.

  • La courbe lit les composantes nommées comme son abscissa_type, une par variable. Sans abscissa_type, ou si le champ n’a pas ces composantes, c’est une erreur.
  • Elle écrit une composante, nommée d’après son ordinate_type, ou "value" à défaut.
  • La politique hors plage s’applique point par point.
etat = pyrucast.NodeField(support, ["T", "phi"])
for noeud, (T, phi) in zip(noeuds, [(20.0, 0.0), (260.0, 0.4), (500.0, 1.0)]):
    etat.unit().set_value(noeud, "T", T)
    etat.unit().set_value(noeud, "phi", phi)

E = young.interpolate(etat[0])  # lit la composante "T", écrit "E"
print(E.components(), E.value(noeuds[1], "E"))  # ['E'] 190000.0

Une Evolution applique toutes ses courbes : k courbes donnent un champ à k composantes, chacune lisant ses propres variables. Deux courbes qui écriraient la même composante sont refusées. C’est ainsi qu’on calcule des propriétés matériau qui dépendent de l’état, par exemple E(T) et σ_y(T, φ) aux points de Gauss en un seul appel, avant de les réunir au besoin aux propriétés constantes ; il n’y a pas d’opérateur matériau à part.

lois = young | sigma_y  # deux courbes : E(T) et σ_y(T, φ)
materiau = lois.interpolate(etat)  # chaque nœud, en ses propres T et φ
print(materiau.components())  # ['E', 'sigma_y']
print(materiau.value(noeuds[1], "sigma_y"))  # 304.0

Composer des courbes

| réunit des SubEvolution en une Evolution, une courbe par zone. Toutes portent le même genre de valeur : des nombres, ou des champs aux nœuds, ou des champs aux points de Gauss. evolution[i] rend la courbe i.

Une Evolution interpolée applique sa politique hors plage à toutes ses courbes. Celle que rendent | et une tranche evolution[i:j] est "error" ; chaque courbe garde la sienne, qui sert quand on l’interpole seule.

zone_a = pyrucast.SubEvolution([(0.0, 1.0), (1.0, 2.0)], out_of_range="clamp")
zone_b = pyrucast.SubEvolution([(0.0, 3.0), (1.0, 4.0)])
deux = zone_a | zone_b
print(deux.interpolate(0.5))  # [1.5, 3.5] : un flottant par courbe
print(deux.out_of_range)  # error : l'agrégat applique la sienne à ses courbes
print(deux.interpolate(2.0, out_of_range="clamp"))  # [2.0, 4.0]

Le long d’une ligne

pyrucast.evolution.along(field, line, components=None) (aussi field.along(line)) rend les valeurs d’un champ aux nœuds le long d’une ligne : une courbe par composante, la valeur en fonction de la distance parcourue. line est un maillage POI1, lu dans l’ordre de ses mailles : cet ordre est le trajet. L’abscisse d’un point est la longueur du trajet jusqu’à lui — la somme des distances en ligne droite d’un point au suivant —, 0 au premier point ; son ordonnée, la valeur de la composante en ce nœud. components=None prend toutes les composantes du champ, dans son ordre ; chacune donne une courbe, dont l’ordinate_type est son nom.

Un champ d’un autre maillage, ou un champ aux points de Gauss, se porte d’abord sur les points de la ligne par pyrucast.node_field.project. Deux points consécutifs confondus sont refusés : l’abscisse se répéterait.

# Une température relevée le long d'un trajet de trois points : la courbe porte
# la valeur en fonction de la distance parcourue — 0, 3, puis 3 + 4 = 7.
plan = pyrucast.Coords(2)
trajet = [plan.add_node(p) for p in ([0.0, 0.0], [3.0, 0.0], [3.0, 4.0])]
ligne = pyrucast.mesh.poi1_from_nodes(trajet)
releve = pyrucast.NodeField(ligne, ["T"])
for noeud, t in zip(trajet, [20.0, 50.0, 90.0]):
    releve[0].set_value(noeud, "T", t)
le_long = pyrucast.evolution.along(releve, ligne)  # aussi releve.along(ligne)
print(le_long[0].abscissas())  # [0.0, 3.0, 7.0]

Tracer

plot() dessine une évolution de nombres en courbe, une ligne par zone, et une évolution de champs en champ sur le maillage, avec un curseur pour choisir l’abscisse dans la fenêtre interactive (voir Visualisation). Sans x_label ni y_label, les axes et le curseur prennent l’abscissa_type et l’ordinate_type. Une table ne se dessine pas en courbe.

courbe = pyrucast.Evolution(
    [(0.0, 20.0), (10.0, 120.0)], abscissa_type="t", ordinate_type="T"
)
courbe.plot(save="courbe.svg")  # axes étiquetés "t" et "T"
chauffe.plot(save="chauffe.png", frame=1)  # le champ du pas tabulé n° 1

Sauvegarde

Une évolution se sauvegarde comme tout objet, avec sa politique hors plage et ses types. Les champs qu’elle tabule gardent leur support, partagé avec les autres objets du même fichier.

Côté Rust

SubEvolution porte :

  • abscissas, un Vec<f64> plat : dims nombres par échantillon, les échantillons triés dans l’ordre de leurs tuples (première variable, puis deuxième…) ;
  • dims, le nombre de variables, de 1 à MAX_ABSCISSAS (4) ;
  • values, un Vec<SubValue>, une valeur par échantillon. SubValue vaut Scalar(f64), Node(SubNodeField) ou Element(SubElementField) : les champs sont rangés dans la courbe, et leur support est un Handle partagé ;
  • out_of_range (OutOfRange), abscissa_type (Vec<String>) et ordinate_type (Option<String>).

SubEvolution::new prend des échantillons dont l’abscisse implémente Abscissa : un f64, un [f64; N] ou un Vec<f64>.

Evolution est l’agrégat de Handle<SubEvolution>, plus sa politique hors plage. check_push refuse une courbe dont les valeurs ne sont pas du même genre que celles des autres. Evolution::interpolate_at rend un Interpolated : Scalars(Vec<f64>), Node(NodeField) ou Element(ElementField). from_scalars, from_node_fields et from_element_fields sont les constructeurs que Python appelle.

Interpolation. Une interrogation pèse au plus deux échantillons par variable, donc au plus 16. gather les trouve par recherche dichotomique, variable après variable, et range leurs poids dans un tableau de taille fixe, sur la pile : les poids ne demandent aucune allocation. Pour des champs, la somme pondérée reprend l’arithmétique des champs (map_all, puis merge_components après check_same_components).

Appliquer à un champ. SubEvolution::interpolate_field et Evolution::interpolate_node_field / interpolate_element_field résolvent une fois, avant de parcourir le champ, quelles composantes chaque courbe lit et quelle politique elle applique. Les lignes du champ sont ensuite traitées en parallèle, sans allocation ; chaque ligne du résultat est écrite une fois, depuis sa propre ligne d’entrée, si bien que le résultat ne dépend pas du nombre de fils.

#[test]
fn une_evolution_interpole_scalaires_et_champs() -> Result<()> {
    // Courbe scalaire X→Y : 0→10, 1→20.
    let se = SubEvolution::new(
        vec![(0.0, SubValue::Scalar(10.0)), (1.0, SubValue::Scalar(20.0))],
        OutOfRange::Error,
    )?;
    match se.interpolate(0.5, None)? {
        SubValue::Scalar(v) => assert_eq!(v, 15.0),
        _ => unreachable!(),
    }
    // Hors plage : Error (défaut) lève ; surcharge Clamp → extrémité.
    assert!(se.interpolate(2.0, None).is_err());

    // Agrégat scalaire → liste de flottants.
    let e = Evolution::from_scalars(vec![(0.0, 10.0), (1.0, 20.0)], OutOfRange::Error)?;
    match e.interpolate(0.5, None)? {
        Interpolated::Scalars(v) => assert_eq!(v, vec![15.0]),
        _ => unreachable!(),
    }
    Ok(())
}

Détails des physiques

Une physique est un sous-modèle (SubModel) que construit un opérateur de pyrucast.model. L’opérateur reçoit son support, un espace éléments finis ou les maillages que relie une contrainte, et rend un modèle qui le couvre en entier : un sous-modèle par zone. Les physiques se composent par | : une conduction et la contrainte qui fige sa température sur un bord forment un seul modèle.

Cette page dit ce qu’ont en commun toutes les physiques : leur tag, leur type, leurs variables, et ce que chacune sait produire. Chaque page qui suit décrit une physique selon le même plan (voir le plan des pages).

Les tags

Chaque sous-modèle porte un ou plusieurs tags, qui disent à quelle physique il appartient. sub.physics() les donne, et un modèle se restreint à un tag (voir Modèle). Les pages sont rangées par tag :

tagphysiques
thermalconduction ; rayonnement, qui porte aussi radiation
diffusiondiffusion de Fick
mechanicalélasticité, plasticité, endommagement, barre, poutres, coques
constraintDirichlet, multi-points, baignage, contact

Les échanges et le flux imposé n’ont pas de tag propre : ils prennent celui de la physique qu’ils visent, thermal sur une conduction, mechanical sur une élasticité. Une contrainte porte constraint et le tag de la physique dont elle fige les inconnues.

Les types de modèle

Une physique est de l’un de trois types, selon ce qu’elle sait faire :

typece qu’il faitphysiques
domaine avec comportementintègre sur un sous-espace éléments finis, lit un matériau, et porte une loi : de ce que la solution produit (gradient, déformation, courbures), elle calcule un flux, une contrainte ou des efforts, plus un éventuel étatconduction, rayonnement, Fick, élasticité, plasticité, endommagement, barre, poutres, coques
domaine sans comportementintègre sur un sous-espace et lit un matériau, sans loiéchange de bord, transfert d’interface, flux imposé
contrainteimpose des relations linéaires entre inconnues par des multiplicateurs de Lagrange ; aucun sous-espace, aucun matériauDirichlet, multi-points, baignage, contact

Ce que les accesseurs d’un sous-modèle répondent :

domaine avec comportementdomaine sans comportementcontrainte
label()le nom de la physique : "HeatConduction", "Radiation", "Fick", "Elasticity", "Plasticity", "Damage", "Truss", "Bernoulli", "Timoshenko", "Shell""BoundaryTransfer", "InterfaceTransfer", "Flux""Dirichlet", "Mpc", "Embedded", "Contact"
material_components()les composantes matériau requisesles composantes requisesNone
fespace()le sous-espace où elle intègrele sous-espace où elle intègre (le côté A d’un transfert d’interface)None
has_behaviorTrueFalseFalse
multiplier_mesh()videvideles nœuds multiplicateurs, un par relation

Seul un domaine avec comportement se prête aux opérateurs de comportement de pyrucast.element_field : strain, integrate_behavior, et la tangente pyrucast.matrix.tangent (voir Comportement).

Les variables

Chaque physique nomme ses variables :

  • les primales (primal_vars()) sont les inconnues, les colonnes de la matrice : T, u_x… ;
  • les duales (dual_vars()) sont les lignes, où se lit le second membre : q, f_x… La duale d’une primale est ce qui travaille avec elle : un flux de chaleur avec une température, une force avec un déplacement ;
  • le matériau est un champ aux points de Gauss qui porte les composantes requises (material_components()) et, au besoin, des composantes facultatives ;
  • l’entrée du comportement est ce que strain(modele, solution, materiaux) calcule depuis la solution : un gradient, une déformation, des courbures ;
  • la sortie du comportement est ce que rend integrate_behavior(modele, entrée, materiaux, prev, dt) : le flux, la contrainte ou les efforts, puis l’état que la loi relira au pas suivant (prev) ;
  • les forces internes (pyrucast.node_field.internal_forces) sont ce que la sortie exerce sur les duales : ∫ Bᵀσ, où B est l’opérateur qui produit l’entrée depuis la solution. Chaque page dit comment sa physique les calcule.

Une contrainte n’a qu’une primale, lambda (le multiplicateur), et une duale, imposed (la ligne de la relation, où s’écrit la valeur imposée).

Ce que chaque physique produit

Les tableaux disent, pour chaque physique, les matrices qu’elle apporte et comment s’obtient sa tangente. Un tiret dit qu’elle n’y contribue rien : une masse assemblée sur un modèle qui contient un échange ignore simplement l’échange.

La tangente est ce que rend pyrucast.matrix.tangent. « = raideur » dit que la physique répond à une demande de tangente par sa raideur ; « analytique » qu’elle a une formule ; « perturbation » qu’elle la calcule par différences finies sur sa loi ; « aucune » qu’elle refuse la demande.

thermal

physiquetyperaideurmassegéométriquetangente
heat_conductionavec comportementconductioncapacité ρ·cp—= raideur
radiationavec comportementfilm linéarisé——analytique

diffusion

physiquetyperaideurmassegéométriquetangente
fickavec comportementdiffusionstockage poro—= raideur

Le tag de la cible

physiquetyperaideurmassegéométriquetangente
boundary_transfersans comportementfilm——= raideur
interface_transfersans comportementquatre blocs——= raideur
fluxsans comportement————

mechanical

physiquetyperaideurmassegéométriquetangente
elasticityavec comportementouiouiouianalytique
plasticity_perfect, plasticity_isotropicavec comportementélastiqueouiouianalytique
drucker_prager, ottosen, gursonavec comportementélastiqueouiouiperturbation
creep_norton, creep_blackburn, creep_lemaitre, viscoplasticity_chaboche, viscoplasticity_lemaitre_chabocheavec comportementélastiqueouiouiperturbation
mazars, damage_tc, damage_sic_sicavec comportementnon endommagéeouiouiaucune
trussavec comportementouiouioui= raideur
bernoulli, timoshenkoavec comportementouiouioui, sauf en 1-D= raideur
shellavec comportementoui——= raideur

constraint

physiquetyperaideurmassegéométriquetangente
dirichlet, mpc, embedded, contactcontrainteblocs C et Cᵀ——= raideur

Le plan des pages

Chaque physique est décrite dans cet ordre :

  1. En bref : ce qu’elle modélise, son opérateur Python, son tag, son type, et les éléments qu’elle accepte ;
  2. Théorie : les équations, de la forme locale à la matrice élémentaire ;
  3. Variables : primales, duales, matériau, entrée et sortie du comportement, état ;
  4. Ce qu’elle produit : matrices, tangente, forces internes et externes ;
  5. Exemple en Python ;
  6. Côté Rust.

Une page qui regroupe plusieurs lois d’une même physique (les lois plastiques, de fluage, d’endommagement) donne l’en-bref et les variables communes une fois, puis la théorie et le matériau loi par loi.

Côté Rust

Une physique est un struct de src/models/ qui implémente le trait SubModelKind : ses variables (primal_vars, dual_vars), ses tags (physics), son nom (label), et ses capacités, qu’elle expose par trois méthodes :

  • as_domain() rend Some(&dyn Domain) pour un domaine : le sous-espace où lire le matériau (material_fespace), les composantes requises et facultatives, les matrices élémentaires ;
  • as_behavior() rend Some(&dyn Behavior) pour un domaine qui porte une loi : son entrée (deformation_reads, produite par strain_operator), sa sortie (behavior_output_components), l’état qu’elle relit (state_reads), sa tangente au point (tangent_point, tangent_source), et requires_dt pour une loi qui dépend du pas de temps ;
  • as_constraint() rend Some(&dyn Constraint) pour une contrainte : ses nœuds multiplicateurs et ses relations.

Le code générique (assembleur, comportement, sauvegarde) ne passe que par ce trait : SubModel::as_kind est le seul match sur les physiques. Les matrices qu’une physique apporte se déclarent par matrix_layout(kind), une par genre (MatrixKind : raideur, masse, géométrique, tangente) ; une contrainte ou un transfert d’interface rend ses blocs par contributions. Pour en écrire une, voir Ajouter une physique.

Conduction thermique

En bref

La conduction de la chaleur dans un solide : la température T est l’inconnue, et la chaleur circule des zones chaudes vers les zones froides.

opérateurpyrucast.model.heat_conduction(fespace, symmetry="isotropic")
tagthermal
typedomaine avec comportement (label() : "HeatConduction")
élémentsSEG2, SEG3 ; TRI3, QUA4, TRI6, QUA8, QUA9 ; TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 ; dans des Coords de leur dimension, ou plus grandes (un fil, une plaque mince)

symmetry choisit la forme de la conductivité : "isotropic" (un scalaire k), "orthotropic" ou "anisotropic" (un tenseur orienté par des axes matériau).

Théorie

En régime transitoire, l’équation de la chaleur s’écrit

\[ \rho\,c_p\,\frac{\partial T}{\partial t} - \nabla\cdot(\mathbf K\,\nabla T) = Q, \]

avec \(\mathbf K\) le tenseur de conductivité, \(\rho\,c_p\) la capacité volumique et \(Q\) une source. En régime permanent, le premier terme disparaît. Multipliée par une température virtuelle \(v\) et intégrée par parties, elle devient

\[ \int_\Omega \nabla v\cdot\mathbf K\,\nabla T\,d\Omega + \int_\Omega v\,\rho\,c_p\,\dot T\,d\Omega = \int_\Omega v\,Q\,d\Omega + \int_\Gamma v\,\bar q\,d\Gamma , \]

où \(\bar q\) est le flux qui entre par le bord. Un bord sans condition est isolé : rien n’y entre.

Avec \(T \approx \sum_j N_j\,T_j\), chaque maille donne une matrice de conduction et une matrice de capacité :

\[ K_{ij} = \int_e \nabla N_i^{\mathsf T}\,\mathbf K\,\nabla N_j\,d\Omega , \qquad C_{ij} = \int_e \rho\,c_p\,N_i\,N_j\,d\Omega , \]

intégrées aux points de Gauss, où le matériau est lu : la conductivité et la capacité peuvent varier à l’intérieur d’une maille. Le système semi-discret est \(C\,\dot T + K\,T = F\) ; un pas d’Euler implicite résout \((C/\Delta t + K)\,T_{n+1} = F + (C/\Delta t)\,T_n\).

Conductivité orientée. Un matériau fibré ou feuilleté conduit mieux dans certaines directions. Le tenseur \(\mathbf K\) est alors construit dans les axes matériau, diagonal (orthotropic, conductivités principales k_1, k_2, k_3) ou complet (anisotropic, k_11…k_33), puis tourné dans les axes globaux. Les axes sont des vecteurs du champ matériau : V1X, V1Y en 2-D, le second axe étant la normale dans le plan ; V1X…V1Z et V2X…V2Z en 3-D, le troisième étant V1 × V2. Ils sont normés en interne et peuvent varier d’une maille à l’autre. En 2-D, k_3 (la direction normale au plan) est demandé aussi : le tenseur est construit en 3-D, puis réduit au plan.

Variables

primaleT
dualeq, le flux de chaleur nodal
matériau requisk ; ou k_1, k_2, k_3 (orthotropic) ; ou k_11, k_12, k_13, k_22, k_23, k_33 (anisotropic) ; orientés, plus les axes : V1X, V1Y en 2-D ; V1X, V1Y, V1Z, V2X, V2Y, V2Z en 3-D
matériau facultatifrho, cp, demandés par la capacité seule
entrée du comportementgrad_T_x, grad_T_y, grad_T_z : le gradient, un par direction de l’espace
sortie du comportementflux_x, flux_y, flux_z
étataucun

La sortie est 𝐊·∇T, le flux de la forme faible. Le flux de Fourier, celui qui va du chaud vers le froid, est son opposé : −𝐊·∇T.

Ce qu’elle produit

raideur\(K\), la conduction
masse\(C\), la capacité ; exige rho et cp
géométrique—
tangente= raideur : la loi est linéaire
forces internes\(q_i = \int \nabla N_i\cdot\text{flux}\), soit \(K\,T\)
forces externesaucune ; une source ou un flux de bord se donne par un chargement, ou par flux

Exemple

Une barre de conductivité 2, chauffée par un flux de 10 à gauche, à 20 à droite. Le profil est linéaire, \(T(x) = 20 + Q\,(1 - x)/k\) :

import pyrucast

# Une barre [0, 1] de quatre SEG2 : un flux Q = 10 entre à gauche, T = 20 à droite.
c = pyrucast.Coords(1)
gauche = c.add_node([0.0])
droite = c.add_node([1.0])
barre = pyrucast.mesh.line(gauche, droite, 4)
fes = pyrucast.FiniteElementSpace(barre)

conduction = pyrucast.model.heat_conduction(fes)
appui = pyrucast.model.dirichlet(
    pyrucast.mesh.poi1_from_nodes([droite]), conduction, "T"
)
modele = conduction | appui
materiaux = pyrucast.element_field.material_field(
    modele, [("k", 2.0), ("rho", 1.0), ("cp", 3.0)]
)

source = pyrucast.NodeField(barre, ["q"])  # sur les nœuds de la barre
source.unit().set_value(gauche, "q", 10.0)
second_membre = source | appui.constraint_rhs([(droite, 20.0)])

K = pyrucast.matrix.stiffness(modele, materiaux)
T = K.solve(second_membre)
print(T.value(gauche, "T"))  # 25.0 = 20 + Q·L/k
print(T.value(droite, "T"))  # 20.0

Le gradient et le flux aux points de Gauss, et les forces internes, qui rendent la source :

gradient = pyrucast.element_field.strain(modele, T, materiaux)
print(gradient.components())  # ['grad_T_x']
print(gradient[0].value(0, 0, "grad_T_x"))  # -5.0

repos = pyrucast.element_field.initial_state(
    modele, materiaux
)  # l'état de départ de la loi
flux = pyrucast.element_field.integrate_behavior(modele, gradient, materiaux, repos)
print(flux[0].value(0, 0, "flux_x"))  # -10.0 = k·∂T/∂x

f_int = pyrucast.node_field.internal_forces(modele, flux, T, materiaux)
print(f_int.value(gauche, "q"))  # 10.0 : la source que la conduction équilibre

Un pas de temps, depuis une barre à 20 partout :

C = pyrucast.matrix.mass(modele, materiaux)  # la capacité ρ·cp ; l'appui n'y entre pas
T0 = pyrucast.NodeField(barre, ["T"])
T0.unit().set_uniform("T", 20.0)

dt = 0.01
A = C / dt + K  # Euler implicite : (C/Δt + K)·T₁ = F + (C/Δt)·T₀
T1 = A.solve(second_membre + (C / dt) * T0)
print(round(T1.value(gauche, "T"), 3))  # 20.306 : la chaleur commence à entrer

Une conductivité orthotrope, d’axe principal (0,6 ; 0,8) :

c2 = pyrucast.Coords(2)
bas = pyrucast.mesh.line(c2.add_node([0.0, 0.0]), c2.add_node([1.0, 0.0]), 2)
haut = pyrucast.mesh.line(c2.add_node([0.0, 1.0]), c2.add_node([1.0, 1.0]), 2)
carre = pyrucast.mesh.sweep(bas, haut, 2)
plaque = pyrucast.FiniteElementSpace(carre)

orientee = pyrucast.model.heat_conduction(plaque, symmetry="orthotropic")
print(orientee[0].material_components())  # ['k_1', 'k_2', 'k_3', 'V1X', 'V1Y']
materiaux = pyrucast.element_field.material_field(
    orientee,
    [("k_1", 12.0), ("k_2", 3.0), ("k_3", 3.0), ("V1X", 0.6), ("V1Y", 0.8)],
)
K = pyrucast.matrix.stiffness(orientee, materiaux)

Côté Rust

HeatConduction (src/models/heat_conduction.rs) garde son sous-espace et sa MaterialSymmetry. Les matrices élémentaires sont des fonctions pures, element_stiffness et element_capacity, qui lisent le matériau ligne par ligne aux points de Gauss par les indices résolus une fois par zone (ElementLayout). Le tenseur orienté est construit par src/models/symmetry.rs, le même code que pour la diffusion et l’élasticité.

Le même problème que l’exemple, en Rust :

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::node_field::{NodeField, SubNodeField};
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::models::symmetry::MaterialSymmetry;
use pyrucast::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

#[test]
fn thermal_line_recovers_analytical_solution() -> Result<()> {
    // ── Problem data ───────────────────────────────────────────────────────
    const K: f64 = 1.0; // conductivité
    const Q: f64 = 10.0; // source de chaleur (flux de Neumann) en x = 0
    const T_IMPOSED: f64 = 20.0; // température imposée en x = 1
    const N_ELEMS: usize = 4;
    let h = 1.0 / N_ELEMS as f64;

    // ── Mesh: a line of SEG2 on [0, 1] ─────────────────────────────────────
    let coords = Handle::new(Coords::new(1)?);
    let nodes: Vec<Node> = (0..=N_ELEMS)
        .map(|i| Node::create_in(coords.clone(), &[i as f64 * h]))
        .collect::<Result<_>>()?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    for i in 0..N_ELEMS {
        mesh.add_cell(&[nodes[i].id(), nodes[i + 1].id()])?;
    }
    let fes = FiniteElementSpace::lagrange1(&mesh)?;

    // ── Modèle : conduction + Dirichlet T = 20 en x = 1 ────────────────────
    // The constraint mints its multiplier beside the imposed node (a fresh
    // co-located node); its value is written there.
    let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(std::slice::from_ref(
        nodes.last().unwrap(),
    ))?);

    let conduction = model::heat_conduction(&fes, MaterialSymmetry::Isotropic)?;
    let dirichlet = model::dirichlet(&imposed, &conduction, "T", Default::default())?;
    let mult = dirichlet.multiplier_mesh()?.node(0, 0, 0)?.id();
    let model = conduction.union(&dirichlet)?;

    // ── Material: uniform k (Dirichlet is skipped automatically) ───────────
    let materials = pyrucast::ops::element_field::material_field(&model, &[("k", K)])?;

    // ── Chargement : source Q en x = 0 (composante duale "q"), valeur imposée
    //    T = 20 at the multiplier node ("imposed" slot) ──────────────────
    let node0 = nodes[0].id();
    let mut load_sm = SubMesh::new(coords.clone(), ElementType::POI1);
    load_sm.add_cell(&[node0])?;
    load_sm.add_cell(&[mult])?;
    let load_sm = Handle::new(load_sm);
    let mut rhs = SubNodeField::from_poi1(&load_sm, vec!["imposed".into(), "q".into()])?;
    rhs.set_value(node0, "q", Q)?;
    rhs.set_value(mult, "imposed", T_IMPOSED)?;
    let rhs = NodeField::from_sub(rhs);

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

    // ── Compared with the analytical solution u(x) = 20 + (Q/k)(1 − x) ─────
    let tol = 1e-10;
    for (i, node) in nodes.iter().enumerate() {
        let x = i as f64 * h;
        let expected = T_IMPOSED + (Q / K) * (1.0 - x);
        let got = solution.value(node.id(), "T")?;
        assert!(
            (got - expected).abs() < tol,
            "T(x={x}) : obtenu {got}, attendu {expected}"
        );
    }
    // La réaction (multiplicateur de Lagrange) équilibre le flux injecté : λ = Q.
    let reaction = solution.value(mult, "lambda")?;
    assert!(
        (reaction - Q).abs() < tol,
        "réaction λ : obtenue {reaction}, attendue {Q}"
    );

    Ok(())
}

Le test de la conduction orientée impose un champ linéaire au bord, qui doit être reproduit à l’intérieur pour tout tenseur constant, puis relit le flux et le compare à 𝐊·∇T calculé à la main :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::element_field::ElementField;
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::symmetry::MaterialSymmetry;
use pyrucast::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

/// `N×N` QUA4 grid on the unit square.
const N: usize = 3;

#[test]
fn orthotropic_conduction_passes_the_linear_patch_test() -> Result<()> {
    const K1: f64 = 12.0; // along the first material axis
    const K2: f64 = 3.0; // transverse
    let theta = 30.0_f64.to_radians();
    let (c, s) = (theta.cos(), theta.sin());

    let (grid, fes, _coords) = unit_square()?;
    let (model, multipliers) = patch_model(&grid, &fes, MaterialSymmetry::Orthotropic)?;
    let materials = pyrucast::ops::element_field::material_field(
        &model,
        &[
            ("k_1", K1),
            ("k_2", K2),
            ("k_3", K1),
            ("V1X", c),
            ("V1Y", s),
        ],
    )?;

    let solution = solve_patch(&model, &materials, &grid, &multipliers)?;

    // The linear field must be reproduced exactly, tensor or no tensor.
    let h = 1.0 / N as f64;
    let tol = 1e-9;
    for j in 0..=N {
        for i in 0..=N {
            let x = i as f64 * h;
            let got = solution.value(grid[j * (N + 1) + i].id(), "T")?;
            assert!((got - x).abs() < tol, "T({x}) = {got}");
        }
    }

    // …and the flux must be the first column of the **rotated** tensor.
    let expect_xx = K1 * c * c + K2 * s * s;
    let expect_yx = (K1 - K2) * c * s;
    let (fx, fy) = uniform_flux(&model, &solution, &fes, &materials, &grid)?;
    assert!(
        (fx - expect_xx).abs() < 1e-9,
        "flux_x = {fx}, expected {expect_xx}"
    );
    assert!(
        (fy - expect_yx).abs() < 1e-9,
        "flux_y = {fy}, expected {expect_yx}"
    );
    Ok(())
}

Rayonnement

En bref

Le rayonnement d’une surface vers un environnement lointain à la température \(T_\infty\) (loi de Stefan-Boltzmann). Il se pose sur le bord d’une conduction et partage ses variables : il entre directement dans sa matrice.

opérateurpyrucast.model.radiation(fespace, target)
tagthermal et radiation
typedomaine avec comportement (label() : "Radiation")
élémentsle bord d’une conduction : SEG2, SEG3 en 2-D ; TRI3, QUA4, TRI6, QUA8, QUA9 en 3-D

fespace est l’espace du bord, target la conduction qu’il refroidit. Une cible qui n’assemble pas T avec sa duale q est refusée : le rayonnement écrirait dans une ligne que personne ne résout.

Les deux tags permettent de garder le rayonnement dans le problème thermique (tag thermal) ou de le prendre seul (tag radiation) pour l’assembler ou l’inspecter à part.

Théorie

Le flux qui quitte la surface vaut

\[ q\cdot n = \sigma\,\varepsilon\,\big(T^4 - T_\infty^4\big), \]

où \(\sigma\) est la constante de Stefan-Boltzmann et \(\varepsilon\) l’émissivité. Aucune normale n’entre dans le calcul : la direction est déjà dans \(q\cdot n\), il ne reste qu’un scalaire et la mesure de la surface.

La loi n’est pas linéaire en \(T\), d’où trois termes :

termeexpression
raideur\(4\sigma\varepsilon T_\infty^3 \int_\Gamma N_i N_j\,d\Gamma\) : le film linéarisé autour de \(T_\infty\)
forces internes\(\int_\Gamma N_i\,\sigma\varepsilon\,(T^4 - T_\infty^4)\,d\Gamma\) : le résidu exact
tangente\(4\sigma\varepsilon T^3 \int_\Gamma N_i N_j\,d\Gamma\), à la température courante

Linéarisée autour de \(T_\infty\), la raideur est une matrice constante : c’est l’opérateur de départ d’une boucle de Newton, et à elle seule un opérateur de point fixe utilisable. La tangente porte la non-linéarité : elle est évaluée à la température de chaque point de Gauss.

sigma vaut par défaut la constante SI, 5,670374419·10⁻⁸ W·m⁻²·K⁻⁴, et T est alors une température absolue, en kelvins : une puissance quatrième ne permet pas de déplacer l’origine. Dans un autre système d’unités, on donne sigma dans le matériau.

Variables

primaleT, celle de la conduction
dualeq, celle de la conduction
matériau requisemis (l’émissivité), T_inf
matériau facultatifsigma
entrée du comportementT, la température aux points de Gauss du bord
sortie du comportementflux, \(\sigma\varepsilon(T^4 - T_\infty^4)\)
étataucun

Ce qu’il produit

raideurle film linéarisé
masse—
géométrique—
tangenteanalytique, \(4\sigma\varepsilon T^3\)
forces internesle flux rayonné, pondéré par \(N_i\)
forces externesaucune

Dans un calcul

Le flux se calcule aux points de Gauss de la face. La température y est interpolée à partir de ses valeurs aux nœuds, \( T_g = \sum_j N_j(x_g)\,T_j \), élevée à la puissance quatrième en ce point, et le flux \( \sigma\varepsilon(T_g^4 - T_\infty^4) \) est intégré avec le poids \( w_g \) de chaque point (la mesure de la surface comprise) :

\[ R_i = \sum_g w_g\, N_i(x_g)\, \sigma\varepsilon\,(T_g^4 - T_\infty^4), \qquad (K_r)_{ij} = \sum_g w_g\, 4\sigma\varepsilon\,T_g^3\, N_i(x_g)\, N_j(x_g) \]

\( R \) est le résidu, que rendent les forces internes, et \( K_r \) sa dérivée exacte par rapport aux températures nodales : la tangente.

Le calcul pas à pas annule le résidu par corrections successives. À chaque pas de temps, pyrucast.stepping part de la température du pas précédent et calcule le résidu de tout le problème — conduction, capacité, chargements, rayonnement. Il résout la correction avec un opérateur qui contient la tangente \( K_r \), prise une fois au début du pas, puis recommence avec le résidu de la nouvelle température, jusqu’à ce que la correction tombe sous precision (\( 10^{-4} \) par défaut) relativement à la température. Le résidu suit donc la température à chaque itération ; l’opérateur, lui, reste celui du début du pas. Comme il contient la dérivée de \( T^4 \), une ou deux corrections suffisent quand le pas de temps est raisonnable (algorithme détaillé dans Calcul pas à pas, Algo 2).

La raideur, film linéarisé autour de \( T_\infty \), est une matrice constante : employée seule à chaque itération, elle donne une itération de point fixe, qui converge plus lentement que la tangente.

Une autre méthode consiste à écrire le rayonnement comme une convection dont le coefficient dépend de la température. Puisque \( T^4 - T_\infty^4 = (T^2 + T_\infty^2)(T + T_\infty)(T - T_\infty) \), le flux vaut \( h_r\,(T - T_\infty) \), avec

\[ h_r = \sigma\varepsilon\,(T^2 + T_\infty^2)(T + T_\infty). \]

On calcule \( h_r \) aux nœuds, avec la température nodale de l’itération en cours, on l’interpole aux points de Gauss, et chaque itération résout un problème de convection : \( \int_\Gamma h_r N_i N_j \) dans la matrice, \( \int_\Gamma h_r\,T_\infty N_i \) au second membre. On recommence avec la nouvelle température jusqu’à ce qu’elle ne bouge plus.

Deux choses changent. \( h_r \) est une sécante, pas la tangente \( 4\sigma\varepsilon T^3 \) : chaque itération corrige moins, et il en faut davantage pour la même précision. Et le problème discret n’est pas tout à fait le même : \( h_r \) interpolé depuis les nœuds n’est pas \( h_r \) calculé avec la température des points de Gauss. Les deux méthodes convergent vers la même solution quand le maillage s’affine ; sur un maillage donné, leurs solutions convergées diffèrent de l’ordre de l’erreur de discrétisation.

Exemple

Une plaque à 1000 K qui rayonne par son bord supérieur vers 300 K. On lit le flux au bord, puis l’écart entre tangente et raideur au coin du bord : un seul segment, de longueur L = 0,5, touche ce coin, où \(\int N_0^2 = L/3\) :

conduction = pyrucast.model.heat_conduction(plaque)
rayonnement = pyrucast.model.radiation(pyrucast.FiniteElementSpace(haut), conduction)
print(rayonnement[0].physics())  # ['thermal', 'radiation']
modele = conduction | rayonnement
materiaux = pyrucast.element_field.material_field(
    modele, [("k", 20.0), ("emis", 0.8), ("T_inf", 300.0)]
)

T = pyrucast.NodeField(carre, ["T"])
T.unit().set_uniform("T", 1000.0)  # en kelvins
entree = pyrucast.element_field.strain(modele, T, materiaux)  # ∇T dedans, T au bord
repos = pyrucast.element_field.initial_state(modele, materiaux)
sortie = pyrucast.element_field.integrate_behavior(modele, entree, materiaux, repos)
print(sortie[1].value(0, 0, "flux"))  # 44995.55… = σε(T⁴ − T∞⁴)

K = pyrucast.matrix.stiffness(modele, materiaux)  # film linéarisé en T∞
Kt = pyrucast.matrix.tangent(
    modele, materiaux, entree, repos
)  # film à la température T
coin = haut.node(0, 0, 0)
print(
    Kt.get(coin, "q", coin, "T") - K.get(coin, "q", coin, "T")
)  # 29.425… = 4σε(T³ − T∞³)·L/3

Côté Rust

Radiation (src/models/radiation.rs) garde son sous-espace de bord. Son noyau de raideur évalue 4σεT∞³ au point de Gauss, son comportement (integrate_point) le flux, et tangent_point la dérivée 4σεT³. STEFAN_BOLTZMANN est la constante par défaut.

Le test compare le flux rayonné à σε(T⁴ − T∞⁴) fois l’aire, et la tangente à une différence finie du résidu :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::element_field::ElementField;
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::radiation::STEFAN_BOLTZMANN;
use pyrucast::models::symmetry::MaterialSymmetry;
use pyrucast::models::Physics;
use pyrucast::ops::element_field;
use pyrucast::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

const EMIS: f64 = 0.8; // emissivity
const T_INF: f64 = 300.0; // far-field temperature (K)
const T_WALL: f64 = 500.0; // temperature imposed on the far side (K)

#[test]
fn the_radiated_flux_matches_stefan_boltzmann() -> Result<()> {
    let (fixture, materials) = radiating_square()?;

    // The boundary is at the uniform wall temperature: interpolate it to the
    // Gauss points, integrate the law, and scatter back to the nodes.
    let temperature = uniform_temperature(&fixture, T_WALL)?;
    let at_gauss = element_field::interp_to_gauss(&temperature, &fixture.boundary_fes)?;
    let state = element_field::behavior::integrate(
        &fixture.radiation,
        &at_gauss,
        &element_field::behavior::initial_state(&fixture.radiation, &materials)?,
        &materials,
        None,
    )?;
    let reaction = pyrucast::ops::node_field::internal_forces(
        &fixture.radiation,
        &state,
        &temperature,
        &materials,
    )?;

    // The radiating edge has unit length, so the total flux is the density.
    let expected = STEFAN_BOLTZMANN * EMIS * (T_WALL.powi(4) - T_INF.powi(4));
    let total: f64 = fixture
        .edge
        .iter()
        .map(|n| reaction.value(n.id(), "q").unwrap_or(0.0))
        .sum();
    assert!(
        (total - expected).abs() < 1e-9 * expected.abs(),
        "radiated flux {total}, expected {expected}"
    );
    Ok(())
}

Diffusion (loi de Fick)

En bref

La diffusion d’une espèce dans un milieu : de l’humidité dans un béton, de l’hydrogène dans un acier, des chlorures dans un enrobage. La concentration est l’inconnue, et l’espèce va des zones riches vers les zones pauvres.

opérateurpyrucast.model.fick(fespace, species, symmetry="isotropic")
tagdiffusion
typedomaine avec comportement (label() : "Fick")
élémentsSEG2, SEG3 ; TRI3, QUA4, TRI6, QUA8, QUA9 ; TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 ; dans des Coords de leur dimension, ou plus grandes (un fil, une plaque mince)

species nomme l’espèce : toutes ses grandeurs portent ce nom (c_H2, j_H2, D_H2), si bien que deux espèces vivent sur le même maillage sans se confondre. Ce qui appartient au milieu ne porte pas le nom de l’espèce : le coefficient de stockage poro et les axes matériau. symmetry choisit la forme de la diffusivité, comme pour la conduction.

L’équation est celle de la conduction, mais la physique est distincte : son tag est diffusion, et non thermal, pour qu’un problème qui couple les deux puisse prendre l’une sans l’autre.

Théorie

La première loi de Fick relie le flux de l’espèce au gradient de concentration, et la conservation de l’espèce donne, avec un coefficient de stockage \(\varphi\) (la porosité pour une espèce qui diffuse dans un solide poreux) :

\[ \mathbf j = -\mathsf D\,\nabla c , \qquad \varphi\,\frac{\partial c}{\partial t} - \nabla\cdot(\mathsf D\,\nabla c) = 0 . \]

C’est l’équation de la chaleur avec \(c\) pour \(T\), \(\mathsf D\) pour \(\mathbf K\) et \(\varphi\) pour \(\rho\,c_p\). Sa forme faible et sa discrétisation sont les mêmes :

\[ K_{ij} = \int_e \nabla N_i^{\mathsf T}\,\mathsf D\,\nabla N_j\,d\Omega , \qquad C_{ij} = \int_e \varphi\,N_i\,N_j\,d\Omega . \]

La diffusivité \(\mathsf D\) est un scalaire (isotropic) ou un tenseur orienté par les axes matériau (orthotropic, anisotropic), construit comme la conductivité.

Variables

Pour l’espèce H2 :

primalec_H2, la concentration
dualej_H2, le flux d’espèce nodal
matériau requisD_H2 ; ou D_H2_1, D_H2_2, D_H2_3 (orthotropic) ; ou D_H2_11, D_H2_12, D_H2_13, D_H2_22, D_H2_23, D_H2_33 (anisotropic) ; orientés, plus les axes : V1X, V1Y en 2-D ; V1X, V1Y, V1Z, V2X, V2Y, V2Z en 3-D
matériau facultatifporo, demandé par le stockage seul
entrée du comportementgrad_c_H2_x, grad_c_H2_y, grad_c_H2_z
sortie du comportementj_H2_x, j_H2_y, j_H2_z
étataucun

La sortie est 𝖣·∇c, le flux de la forme faible ; le flux de Fick est son opposé. Elle est nommée d’après la duale de l’espèce, si bien qu’un modèle qui porte une conduction et plusieurs espèces garde des flux distincts.

Ce qu’elle produit

raideur\(K\), la diffusion
masse\(C\), le stockage ; exige poro
géométrique—
tangente= raideur : la loi est linéaire
forces internes\(\int \nabla N_i\cdot\mathbf j\), soit \(K\,c\)
forces externesaucune ; un flux entrant se donne par un chargement, ou par flux

Exemple

Une barre de diffusivité 0,5 et de longueur 2, où un flux d’hydrogène de 1 entre à gauche, la concentration étant nulle à droite. Le multiplicateur de l’appui est le flux qui sort, égal au flux qui entre :

import pyrucast

# Une barre [0, 2] : un flux d'hydrogène de 1 entre à gauche, c = 0 à droite.
c = pyrucast.Coords(1)
gauche = c.add_node([0.0])
droite = c.add_node([2.0])
barre = pyrucast.mesh.line(gauche, droite, 4)
fes = pyrucast.FiniteElementSpace(barre)

fick = pyrucast.model.fick(fes, "H2")
print(fick[0].primal_vars(), fick[0].dual_vars())  # ['c_H2'] ['j_H2']
appui = pyrucast.model.dirichlet(pyrucast.mesh.poi1_from_nodes([droite]), fick, "c_H2")
modele = fick | appui
materiaux = pyrucast.element_field.material_field(modele, [("D_H2", 0.5)])

source = pyrucast.NodeField(barre, ["j_H2"])
source.unit().set_value(gauche, "j_H2", 1.0)
c_h2 = pyrucast.matrix.stiffness(modele, materiaux).solve(
    source | appui.constraint_rhs([(droite, 0.0)])
)
print(round(c_h2.value(gauche, "c_H2"), 9))  # 4.0 = j·L/D
print(
    round(c_h2.value(appui.multiplier_mesh().node(0, 0, 0), "lambda"), 9)
)  # 1.0 : le flux qui sort

gradient = pyrucast.element_field.strain(modele, c_h2, materiaux)
repos = pyrucast.element_field.initial_state(
    modele, materiaux
)  # l'état de départ de la loi
flux = pyrucast.element_field.integrate_behavior(modele, gradient, materiaux, repos)
print(flux.components(), flux[0].value(0, 0, "j_H2_x"))  # ['j_H2_x'] -1.0 = D·∂c/∂x

Deux espèces et une conduction sur le même maillage forment un système à trois blocs indépendants :

melange = (
    pyrucast.model.fick(fes, "H2")
    | pyrucast.model.fick(fes, "O2")
    | pyrucast.model.heat_conduction(fes)
)
print(melange.primal_vars())  # ['c_H2', 'c_O2', 'T']
print(len(melange.filter("diffusion")), len(melange.filter("thermal")))  # 2 1
materiaux = pyrucast.element_field.material_field(
    melange, [("D_H2", 0.5), ("D_O2", 0.1), ("k", 2.0)]
)
K = pyrucast.matrix.stiffness(melange, materiaux)  # trois blocs, sans couplage

Une diffusivité qui dépend de la température se calcule par une évolution appliquée au champ de température, avant chaque assemblage. Un transfert d’espèce à travers une interface entre deux corps est un transfert d’interface.

Côté Rust

Fick (src/models/fick.rs) garde son sous-espace, sa MaterialSymmetry et le nom de l’espèce. Ses noyaux, element_stiffness et element_storage, ont la forme de ceux de la conduction et partagent avec eux la construction du tenseur orienté (symmetry::transport_tensor_from). Les noms se construisent depuis l’espèce : fick::primal_var("H2") rend c_H2, fick::dual_var rend j_H2.

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::node_field::{NodeField, SubNodeField};
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::models::fick::{dual_var, primal_var};
use pyrucast::models::symmetry::MaterialSymmetry;
use pyrucast::models::{Physics, IMPOSED, MULTIPLIER};
use pyrucast::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

/// The diffusing species — every name of this physics carries it.
const SPECIES: &str = "H2";

#[test]
fn fick_line_recovers_the_linear_profile() -> Result<()> {
    const D: f64 = 2.0; // diffusivity
    const J: f64 = 10.0; // injected species flux at x = 0
    const C_IMPOSED: f64 = 1.0; // concentration imposed at x = 1
    const N_ELEMS: usize = 4;
    let h = 1.0 / N_ELEMS as f64;

    // ── Mesh: a line of SEG2 on [0, 1] ─────────────────────────────────────
    let coords = Handle::new(Coords::new(1)?);
    let nodes: Vec<Node> = (0..=N_ELEMS)
        .map(|i| Node::create_in(coords.clone(), &[i as f64 * h]))
        .collect::<Result<_>>()?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    for i in 0..N_ELEMS {
        mesh.add_cell(&[nodes[i].id(), nodes[i + 1].id()])?;
    }
    let fes = FiniteElementSpace::lagrange1(&mesh)?;

    // ── Modèle : diffusion + Dirichlet c = 1 en x = 1 ──────────────────────
    let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(std::slice::from_ref(
        nodes.last().unwrap(),
    ))?);
    let diffusion = model::fick(&fes, SPECIES, MaterialSymmetry::Isotropic)?;
    let dirichlet = model::dirichlet(
        &imposed,
        &diffusion,
        &primal_var(SPECIES),
        Default::default(),
    )?;
    let mult = dirichlet.multiplier_mesh()?.node(0, 0, 0)?.id();
    let model = diffusion.union(&dirichlet)?;

    // ── Matériau : diffusivité uniforme ────────────────────────────────────
    let materials =
        pyrucast::ops::element_field::material_field(&model, &[(&format!("D_{SPECIES}"), D)])?;

    // ── Loading: flux J at x = 0, imposed concentration at the multiplier
    let node0 = nodes[0].id();
    let mut load_sm = SubMesh::new(coords.clone(), ElementType::POI1);
    load_sm.add_cell(&[node0])?;
    load_sm.add_cell(&[mult])?;
    let load_sm = Handle::new(load_sm);
    let mut rhs = SubNodeField::from_poi1(&load_sm, vec![IMPOSED.to_string(), dual_var(SPECIES)])?;
    rhs.set_value(node0, &dual_var(SPECIES), J)?;
    rhs.set_value(mult, IMPOSED, C_IMPOSED)?;
    let rhs = NodeField::from_sub(rhs);

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

    // ── Compared with the analytical profile c(x) = 1 + (J/D)(1 − x) ───────
    let tol = 1e-10;
    for (i, node) in nodes.iter().enumerate() {
        let x = i as f64 * h;
        let expected = C_IMPOSED + (J / D) * (1.0 - x);
        let got = solution.value(node.id(), &primal_var(SPECIES))?;
        assert!(
            (got - expected).abs() < tol,
            "c(x={x}) : {got} ≠ {expected}"
        );
    }
    // Mass balance: the reaction at the imposed edge balances the injected flux.
    let reaction = solution.value(mult, MULTIPLIER)?;
    assert!((reaction - J).abs() < tol, "réaction λ : {reaction} ≠ {J}");
    Ok(())
}

Échanges et flux imposé

Trois physiques sans comportement, qui s’appliquent à une autre physique, leur cible, et en prennent les variables et le tag :

  • l’échange de bord, entre un bord et un milieu extérieur connu ;
  • le transfert d’interface, entre deux maillages qui se font face ;
  • le flux imposé, une densité donnée sur un bord ou un volume.

Les deux échanges suivent la même loi : un flux proportionnel à un écart,

\[ q\cdot n = h\,(a - b). \]

Pour l’échange de bord, \(b\) est une donnée, la valeur ambiante ; pour le transfert d’interface, c’est une inconnue, le champ de l’autre maillage. Les deux partagent leur noyau, transfer::exchange_matrix.

Ce qui est échangé est donné par l’appelant. On passe la liste des variables échangées (["T"], ["c_H2"], ["u_x", "u_y"]). Chacune doit être une inconnue de la cible, qui lui donne sa duale, la ligne où le flux est écrit. Le coefficient \(h\) est un par variable, nommé d’après elle : h_T, h_c_H2, h_u_x. L’échange prend le tag de la cible : thermal sur une conduction, diffusion sur une diffusion, mechanical sur une élasticité. Un nom inconnu de la cible, ou des variables de deux tags dans un même échange, sont refusés à la construction.

Échange de bord

En bref

Un échange entre un bord et un milieu extérieur : la convection avec un fluide (loi de Newton), le transfert de masse en surface, ou, sur les déplacements, une fondation élastique.

opérateurpyrucast.model.boundary_transfer(fespace, target, variables)
tagcelui de la cible
typedomaine sans comportement (label() : "BoundaryTransfer")
élémentsle bord de la cible : SEG2, SEG3 en 2-D ; TRI3, QUA4, TRI6, QUA8, QUA9 en 3-D

Théorie

Sur le bord \(\Gamma\), le flux sortant vaut \(h\,(a - a_\text{ext})\). Dans la forme faible, il donne une matrice de film et un second membre :

\[ K_{ij} = \int_\Gamma h\,N_i\,N_j\,d\Gamma , \qquad f_i = \int_\Gamma h\,a_\text{ext}\,N_i\,d\Gamma . \]

Aucune normale n’entre dans le calcul : elle est déjà dans \(q\cdot n\), et il ne reste sous l’intégrale qu’un scalaire et la mesure du bord, qui ne dépend pas de l’orientation des mailles. Le film rend la matrice définie : un problème avec un flux imposé et un échange, sans aucune température imposée, a une solution unique.

Variables

Pour la variable T d’une conduction :

primaleT, celle de la cible
dualeq, celle que la cible associe à T
matériau requish_T, a_ext_T (la valeur ambiante)
comportementaucun ; les forces internes lisent T interpolée aux points de Gauss

Ce qu’il produit

raideurle film, \(\int h\,N_i\,N_j\)
masse, géométrique—
tangente= raideur
forces internes\(\int h\,a\,N_i\)
forces externes\(\int h\,a_\text{ext}\,N_i\), rendues par pyrucast.node_field.external_forces

Exemple

Un carré de conductivité 2, avec une source de 10 sur le bord gauche (un flux imposé) et un film h = 5 vers 20 sur le bord droit. Tout ce qui entre ressort par le film, d’où \(T = T_\text{ext} + Q/h\) à droite et \(T_\text{ext} + Q/h + Q/k\) à gauche :

import pyrucast

# Un carré [0, 1]² : ses bords gauche et droit sont les deux lignes balayées.
c = pyrucast.Coords(2)
gauche = pyrucast.mesh.line(c.add_node([0.0, 0.0]), c.add_node([0.0, 1.0]), 2)
droite = pyrucast.mesh.line(c.add_node([1.0, 0.0]), c.add_node([1.0, 1.0]), 2)
carre = pyrucast.mesh.sweep(gauche, droite, 2)

conduction = pyrucast.model.heat_conduction(pyrucast.FiniteElementSpace(carre))
source = pyrucast.model.flux(pyrucast.FiniteElementSpace(gauche), conduction, "q")
film = pyrucast.model.boundary_transfer(
    pyrucast.FiniteElementSpace(droite), conduction, ["T"]
)
print(source[0].physics(), film[0].physics())  # ['thermal'] ['thermal']
modele = conduction | source | film
materiaux = pyrucast.element_field.material_field(
    modele, [("k", 2.0), ("phi_q", 10.0), ("h_T", 5.0), ("a_ext_T", 20.0)]
)

second_membre = pyrucast.node_field.external_forces(
    modele, materiaux
)  # φ à gauche, h·T_ext à droite
T = pyrucast.matrix.stiffness(modele, materiaux).solve(second_membre)
print(round(T.value(droite.node(0, 0, 0), "T"), 9))  # 22.0 = T_ext + Q/h
print(round(T.value(gauche.node(0, 0, 0), "T"), 9))  # 27.0 = T_ext + Q/h + Q/k

Le même échange sur les déplacements est une fondation élastique : un ressort réparti par direction.

plaque = pyrucast.model.elasticity(pyrucast.FiniteElementSpace(carre), "plane_stress")
semelle = pyrucast.model.boundary_transfer(
    pyrucast.FiniteElementSpace(gauche), plaque, ["u_x", "u_y"]
)
print(semelle[0].physics())  # ['mechanical']
print(semelle[0].material_components())  # ['h_u_x', 'h_u_y', 'a_ext_u_x', 'a_ext_u_y']

Transfert d’interface

En bref

Un échange entre deux maillages qui se font face sans partager leurs nœuds : une résistance de contact, un revêtement, un joint, une membrane. Le champ saute à la traversée, et le flux qui passe est proportionnel au saut.

opérateurpyrucast.model.interface_transfer(side_a, side_b, target, variables, tol=1e-9)
tagcelui de la cible
typedomaine sans comportement (label() : "InterfaceTransfer")
élémentsdeux bords conformes

side_a et side_b sont les espaces des deux faces. Elles doivent être conformes : même type de maille, même nombre de mailles, la maille i face à la maille i, et leur nœud local k à moins de tol l’un de l’autre. C’est vérifié à la construction, et une interface non conforme est refusée. Un sous-modèle est créé par paire de zones des deux côtés, prises dans l’ordre.

Théorie

La forme faible du terme d’interface est

\[ \int_\Gamma h\,(a_1 - a_2)\,(\delta a_1 - \delta a_2)\,d\Gamma , \]

qui donne quatre blocs sur les inconnues des deux côtés :

\[ \begin{bmatrix} +K & -K \\ -K & +K \end{bmatrix}, \qquad K_{ij} = \int_\Gamma h\,N_i\,N_j\,d\Gamma . \]

Les deux blocs diagonaux sont deux films, un par côté. Les deux autres ont leurs lignes sur un maillage et leurs colonnes sur l’autre ; chacun seul n’est pas symétrique, leur paire l’est. Les variables échangées ne se couplent pas entre elles : la chaleur qui traverse un joint ne pousse pas l’hydrogène.

C’est la version par pénalité de ce qu’un multi-points impose exactement : quand \(h\) grandit, le saut tend vers zéro. Si \(h\) est une donnée physique (une résistance de contact mesurée), c’est un transfert ; s’il n’est là que pour lier deux surfaces, c’est une contrainte.

mpcinterface_transfer
inconnuesajoute lambdaaucune
matricepoint-selledéfinie positive
quand \(h\) grandit—la matrice se conditionne mal

Variables

Pour la variable T :

primaleT, des deux côtés
dualeq, des deux côtés
matériau requish_T
comportementaucun ; les forces internes lisent le saut jump_T aux points de Gauss

Ce qu’il produit

raideurles quatre blocs
masse, géométrique—
tangente= raideur
forces internes\(\int h\,(a_1 - a_2)\,N_i\) sur le côté A, son opposé sur le côté B
forces externesaucune

Exemple

Deux carrés côte à côte, un joint h = 4 entre eux, un flux de 1 entrant à gauche et T = 0 à droite. Le saut à la traversée vaut \(Q/h\) :

# Deux carrés côte à côte ; la ligne x = 1 existe deux fois, avec des nœuds distincts.
c = pyrucast.Coords(2)
x0 = pyrucast.mesh.line(c.add_node([0.0, 0.0]), c.add_node([0.0, 1.0]), 2)
x1_g = pyrucast.mesh.line(c.add_node([1.0, 0.0]), c.add_node([1.0, 1.0]), 2)
x1_d = pyrucast.mesh.line(c.add_node([1.0, 0.0]), c.add_node([1.0, 1.0]), 2)
x2 = pyrucast.mesh.line(c.add_node([2.0, 0.0]), c.add_node([2.0, 1.0]), 2)
corps = pyrucast.model.heat_conduction(
    pyrucast.FiniteElementSpace(pyrucast.mesh.sweep(x0, x1_g, 2))
) | pyrucast.model.heat_conduction(
    pyrucast.FiniteElementSpace(pyrucast.mesh.sweep(x1_d, x2, 2))
)
joint = pyrucast.model.interface_transfer(
    pyrucast.FiniteElementSpace(x1_g), pyrucast.FiniteElementSpace(x1_d), corps, ["T"]
)
source = pyrucast.model.flux(pyrucast.FiniteElementSpace(x0), corps, "q")
fixe = x2.to_poi1()  # les trois nœuds de x = 2
appui = pyrucast.model.dirichlet(fixe, corps, "T")
modele = corps | joint | source | appui
materiaux = pyrucast.element_field.material_field(
    modele, [("k", 1.0), ("h_T", 4.0), ("phi_q", 1.0)]
)

second_membre = pyrucast.node_field.external_forces(
    modele, materiaux
) | appui.constraint_rhs([(fixe.node(0, i, 0), 0.0) for i in range(3)])
T = pyrucast.matrix.stiffness(modele, materiaux).solve(second_membre)
saut = T.value(x1_g.node(0, 0, 0), "T") - T.value(x1_d.node(0, 0, 0), "T")
print(round(saut, 9))  # 0.25 = Q/h : le champ saute à la traversée

Flux imposé

En bref

Une densité donnée, intégrée en forces nodales : une source de chaleur sur un bord ou dans un volume, une traction, une pression. Elle ne dépend pas de la solution, et n’entre dans aucune matrice.

opérateurpyrucast.model.flux(fespace, target, dual)
tagcelui de la cible
typedomaine sans comportement (label() : "Flux")
élémentstoute maille, en volume ou sur un bord

dual est la ligne de la cible où la densité est écrite : "q" pour une source de chaleur, "f_x" pour une traction en x. "n" désigne une pression : la densité agit selon la normale de la maille et s’écrit dans toutes les lignes de déplacement (f_x, f_y, f_z). La normale est celle de la configuration active : sur une configuration déformée, la pression suit la paroi.

Théorie

\[ f_i = \int \varphi\,N_i\,d\Gamma , \qquad f_{i,a} = \int \varphi\,n_a\,N_i\,d\Gamma \;\text{pour une pression.} \]

Variables

primaleaucune
dualedual, prise dans la cible
matériau requisphi_<dual> : phi_q, phi_f_x, phi_n
comportementaucun

La densité est une composante du champ matériau : uniforme, ou variable d’un point de Gauss à l’autre.

Ce qu’il produit

raideur, masse, géométrique, tangente—
forces internesaucune
forces externes\(\int \varphi\,N_i\), rendues par pyrucast.node_field.external_forces

Exemple

La source de l’échange de bord ci-dessus est un flux imposé : model.flux(gauche, conduction, "q") et la composante phi_q du matériau.

Côté Rust

BoundaryTransfer (src/models/boundary_transfer.rs) et InterfaceTransfer (src/models/interface_transfer.rs) s’appuient sur src/models/transfer.rs, qui porte leur noyau commun, exchange_matrix, la vérification des variables contre la cible et le choix du tag (owner_physics). Ils implémentent Domain sans Behavior. Ce qu’ils lisent pour leurs forces internes est la primale interpolée aux points de Gauss (residual_input), et non un état.

Le transfert d’interface rend ses quatre blocs par contributions : deux blocs ordinaires et deux blocs de couplage (Contribution::Coupling), dont les lignes et les colonnes vivent sur deux sous-espaces différents.

Flux (src/models/flux.rs) ne contribue à aucune matrice ; il déclare seulement son terme de forces externes (external_force_contribution).

Le test de la fondation élastique compare le déplacement d’une barre poussée sur un ressort réparti à \(u = q / (E/L + h)\), sur quatre décades de raideur :

/// A boundary exchange on the displacement is an **elastic foundation**: the
/// face rests on a distributed spring, and the analytical `u = q/(E/L + h)`
/// comes out for stiffnesses spanning four decades.
#[test]
fn a_boundary_exchange_on_displacements_is_an_elastic_foundation() -> Result<()> {
    for h in [0.0, 1e3, 1e4, 1e5, 1e6] {
        let u = free_face_displacement(4, h)?;
        let exact = Q / (E / L + h);
        assert!(
            (u - exact).abs() < 1e-9 * exact.abs().max(1e-12),
            "h = {h}: face displacement {u}, exact {exact}"
        );
    }
    Ok(())
}

Le test du transfert d’interface vérifie le profil de part et d’autre et le saut \(q/h\) :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::element_field::ElementField;
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::interface_transfer::DEFAULT_TOL;
use pyrucast::models::symmetry::MaterialSymmetry;
use pyrucast::models::{Physics, IMPOSED};
use pyrucast::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

/// The diffusing species — every name of the Fick physics carries it.
const SPECIES: &str = "H2";

const D: f64 = 2.0; // diffusivity, both squares
const Q: f64 = 10.0; // flux density injected at x = 0
const C_RIGHT: f64 = 1.0; // concentration imposed at x = 2

#[test]
fn an_interface_law_makes_the_field_jump() -> Result<()> {
    const H: f64 = 5.0; // transfer coefficient
    let (geom, solution) = solve_two_squares(H)?;

    let c = |n: &Node| solution.value(n.id(), &format!("c_{SPECIES}"));
    let far_left = c(&geom.left[0])?; // (0, 0)
    let left_face = c(&geom.left[1])?; // (1, 0), left side
    let right_face = c(&geom.right[0])?; // (1, 0), right side
    let far_right = c(&geom.right[1])?; // (2, 0)

    let tol = 1e-10;
    assert!((far_right - C_RIGHT).abs() < tol, "c(2) = {far_right}");
    // Slope q/D over the unit width of each square.
    assert!(
        (far_left - left_face - Q / D).abs() < tol,
        "left square: {far_left} → {left_face}"
    );
    assert!(
        (right_face - far_right - Q / D).abs() < tol,
        "right square: {right_face} → {far_right}"
    );
    // …and the jump across the interface is q/h — the exchange law itself.
    let jump = left_face - right_face;
    assert!(
        (jump - Q / H).abs() < tol,
        "jump = {jump}, expected {}",
        Q / H
    );
    Ok(())
}

Mécanique

Les physiques de tag mechanical. Leurs inconnues sont les déplacements u_x, u_y, u_z (et les rotations r_x, r_y, r_z des poutres et des coques), leurs duales les forces nodales f_x, f_y, f_z (et les moments m_x, m_y, m_z).

Milieux continus, une même cinématique (B, la déformation tirée du déplacement) et une loi qui change :

Éléments de structure, dont les efforts sont intégrés sur la section :

Ce que partagent les milieux continus

Une loi de milieu continu se déclare par pyrucast.model.<loi>(fespace, kinematics, …, strain_measure="linear").

kinematics est l’hypothèse cinématique :

kinematicsespacecomposantes de la déformation et de la contrainte
"plane_stress"2-D, \(\sigma_{zz} = 0\)xx, yy, xy
"plane_strain"2-D, \(\varepsilon_{zz} = 0\)xx, yy, xy
"axisymmetric"2-D, plan méridien d’un solide de révolutionxx, yy, zz, xy, où zz est l’orthoradiale \(\theta\theta\)
"full_3d"3-Dxx, yy, zz, yz, xz, xy

L’axisymétrie demande des Coords de révolution (voir Coordonnées), et réciproquement : un modèle plan est refusé sur une géométrie de révolution. La déformation suit la convention de l’ingénieur : le glissement \(\gamma_{xy} = 2\varepsilon_{xy}\) est rangé dans eps_xy.

Les lois plastiques et de fluage intègrent leur état en 3-D, quelle que soit la cinématique, et rendent en plus de la contrainte la déformation totale et, pour les modèles plans, sigma_zz. La sortie d’un pas de toute loi non linéaire est complète : elle sert telle quelle de prev au pas suivant.

Le matériau accepte deux composantes facultatives : rho (la masse) et alpha (la dilatation thermique, lue par pyrucast.element_field.thermal_strain).

Barre

En bref

Une barre ne transmet que l’effort axial : un treillis, un câble tendu, un tirant. Elle se pose sur des segments, en 1-D, 2-D ou 3-D.

opérateurpyrucast.model.truss(fespace, strain_measure="linear")
tagmechanical
typedomaine avec comportement (label() : "Truss")
élémentsSEG2

Une barre n’a aucune raideur transversale : chaque nœud doit être tenu dans les directions que la barre ne porte pas (par d’autres barres, un treillis triangulé, ou des appuis), sinon la matrice est singulière. Tout autre élément qu’un SEG2 est refusé, comme une géométrie de révolution.

Théorie

Le long de l’axe \(s\) de la barre, de section \(A\) :

\[ N = E\,A\,\varepsilon, \qquad \varepsilon = \frac{du_s}{ds}, \qquad \frac{dN}{ds} + f = 0 . \]

La direction de la barre est le vecteur unitaire \(c = (x_B - x_A)/L\), tiré des coordonnées des nœuds. Avec l’interpolation linéaire, la déformation est constante dans l’élément, et la raideur s’écrit directement dans les axes globaux, sans matrice de repère :

\[ K_e = \frac{E\,A}{L} \begin{bmatrix} c\,c^{\mathsf T} & -c\,c^{\mathsf T} \\ -c\,c^{\mathsf T} & c\,c^{\mathsf T} \end{bmatrix}. \]

En 1-D, \(c = 1\) et l’on retrouve \((EA/L)\,[[1, -1], [-1, 1]]\). La masse est \((\rho A L/6)\,[[2, 1], [1, 2]]\) sur chaque direction, et la raideur géométrique sous l’effort \(N\) vaut \((N/L)\,[[I, -I], [-I, I]]\) : elle raidit toutes les directions, l’axiale comprise.

Avec strain_measure="green_lagrange", la barre suit les grands déplacements : voir Grandes transformations.

Variables

Selon la dimension des Coords :

1-D2-D3-D
primalesu_xu_x, u_yu_x, u_y, u_z
dualesf_xf_x, f_yf_x, f_y, f_z
entrée du comportementeps_xxeps_xx, eps_yy, eps_xyeps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy
sortie du comportementnnn

n est l’effort axial \(E\,A\,c^{\mathsf T}\varepsilon\,c\), lu le long de la barre dans la déformation du point.

matériau requisE, A
matériau facultatifrho
étataucun

Ce qu’elle produit

raideur\(K_e\)
masseexige rho
géométriquesous l’effort n
tangente= raideur
forces internes\(n\,c\) aux deux nœuds, de signes opposés : le même \(B\) que la raideur

Exemple

Une barre de longueur 2, de module 200 000 et de section 0,01, tirée par une force de 1000 : \(u = F\,L/(E\,A) = 1\).

import pyrucast

# Une barre de longueur 2, fixée à gauche, guidée en y à droite, tirée par F = 1000.
c = pyrucast.Coords(2)
a = c.add_node([0.0, 0.0])
b = c.add_node([2.0, 0.0])
ligne = pyrucast.mesh.line(a, b, 1)
barre = pyrucast.model.truss(pyrucast.FiniteElementSpace(ligne))
appuis = (
    pyrucast.model.dirichlet(pyrucast.mesh.poi1_from_nodes([a]), barre, "u_x")
    | pyrucast.model.dirichlet(pyrucast.mesh.poi1_from_nodes([a]), barre, "u_y")
    | pyrucast.model.dirichlet(pyrucast.mesh.poi1_from_nodes([b]), barre, "u_y")
)
modele = barre | appuis
materiaux = pyrucast.element_field.material_field(modele, [("E", 200e3), ("A", 0.01)])

force = pyrucast.NodeField(ligne, ["f_x"])
force.unit().set_value(b, "f_x", 1000.0)
u = pyrucast.matrix.stiffness(modele, materiaux).solve(force)  # valeurs imposées nulles
print(u.value(b, "u_x"))  # 1.0 = F·L/(E·A)

deformation = pyrucast.element_field.strain(modele, u, materiaux)
repos = pyrucast.element_field.initial_state(
    modele, materiaux
)  # l'état de départ de la loi
effort = pyrucast.element_field.integrate_behavior(
    modele, deformation, materiaux, repos
)
print(effort.components(), effort[0].value(0, 0, "n"))  # ['n'] 1000.0

Côté Rust

Truss (src/models/truss.rs) calcule ses matrices par des fonctions pures, element_stiffness, element_mass et element_geometric, et ses forces internes par internal_force_element, qui applique le même \(B\) que la raideur : pour une loi linéaire, \(\int B^{\mathsf T}\sigma\) vaut exactement \(K\,u\).

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::containers::node_field::{NodeField, SubNodeField};
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::ops::element_field::StrainMeasure;
use pyrucast::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

#[test]
fn truss_bar_recovers_axial_elongation() -> Result<()> {
    const E: f64 = 210.0e9; // Young's modulus (Pa)
    const A: f64 = 1.0e-4; // section area (m²)
    const L: f64 = 2.0; // length (m)
    const F: f64 = 1000.0; // axial force at the right end (N)

    // ── Mesh: one horizontal SEG2 bar ──────────────────────────────────────
    let coords = Handle::new(Coords::new(2)?);
    let n0 = Node::create_in(coords.clone(), &[0.0, 0.0])?;
    let n1 = Node::create_in(coords.clone(), &[L, 0.0])?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    mesh.add_cell(&[n0.id(), n1.id()])?;
    let fes = FiniteElementSpace::lagrange1(&mesh)?;

    // ── Modèle : barre + appuis (Dirichlet homogènes) ──────────────────────
    // Homogeneous (u = 0) BCs: the imposed value defaults to 0, so we only need
    // to introduce the constraint. A bar has no transverse stiffness, hence
    // `u_y` is clamped at both nodes to make the system well-posed.
    let clamp = |target: &Model, node: &Node, var: &str| -> Result<Model> {
        let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(std::slice::from_ref(node))?);
        model::dirichlet(&imposed, target, var, Default::default())
    };
    let mut model = model::truss(&fes, StrainMeasure::Linear)?;
    model = model.union(&clamp(&model, &n0, "u_x")?)?;
    model = model.union(&clamp(&model, &n0, "u_y")?)?;
    model = model.union(&clamp(&model, &n1, "u_y")?)?;

    // ── Matériau E, A (Dirichlet ignoré automatiquement) ───────────────────
    let materials = pyrucast::ops::element_field::material_field(&model, &[("E", E), ("A", A)])?;

    // ── Loading: axial force F at the right node ───────────────────────────
    let mut load_sm = SubMesh::new(coords.clone(), ElementType::POI1);
    load_sm.add_cell(&[n1.id()])?;
    let load_sm = Handle::new(load_sm);
    let mut rhs = SubNodeField::from_poi1(&load_sm, vec!["f_x".into()])?;
    rhs.set_value(n1.id(), "f_x", F)?;
    let rhs = NodeField::from_sub(rhs);

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

    // ── Comparaison à l'analytique : u_x = F·L / (E·A) ─────────────────────
    let expected = F * L / (E * A);
    let ux = solution.value(n1.id(), "u_x")?;
    assert!(
        (ux - expected).abs() < 1e-10 * expected,
        "u_x = {ux}, attendu {expected}"
    );
    // The left node is clamped, the right end does not move transversally.
    assert!(solution.value(n0.id(), "u_x")?.abs() < 1e-18);
    assert!(solution.value(n1.id(), "u_y")?.abs() < 1e-18);

    Ok(())
}

Élasticité linéaire

En bref

Un solide qui se déforme réversiblement, proportionnellement à la charge (loi de Hooke), en petites déformations.

opérateurpyrucast.model.elasticity(fespace, kinematics, symmetry="isotropic", strain_measure="linear")
tagmechanical
typedomaine avec comportement (label() : "Elasticity")
éléments2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27
cinématiquesplane_stress, plane_strain, axisymmetric, full_3d

kinematics est l’hypothèse cinématique (voir Mécanique), symmetry la symétrie du matériau (voir Orthotropie), strain_measure la mesure de déformation (voir Grandes transformations). Cette page décrit le cas isotrope en petites déformations.

Un maillage de bord (des segments en 2-D) est refusé comme domaine : \(B\) y serait bâti sur le seul gradient tangent, et la raideur n’aurait aucune rigidité dans la direction normale.

Théorie

Sur le domaine \(\Omega\), avec \(b\) les forces de volume :

\[ \nabla\cdot\sigma + b = 0, \qquad \sigma = \mathbb D : \varepsilon, \qquad \varepsilon = \tfrac12\big(\nabla u + \nabla u^{\mathsf T}\big). \]

La forme faible : pour tout déplacement virtuel \(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 \(\Gamma_N\).

Avec \(u \approx \sum_i N_i\,u_i\), la déformation s’écrit \(\varepsilon = B\,u_e\). En 2-D, \(\varepsilon = [\varepsilon_{xx}, \varepsilon_{yy}, \gamma_{xy}]\) et 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}; \]

en 3-D, \(\varepsilon = [\varepsilon_{xx}, \varepsilon_{yy}, \varepsilon_{zz}, \gamma_{yz}, \gamma_{xz}, \gamma_{xy}]\) avec un bloc de six lignes de même forme. La raideur et la masse de l’élément sont

\[ K_e = \int_{\Omega_e} B^{\mathsf T} D\,B\,d\Omega , \qquad M_e = \int_{\Omega_e} \rho\,N^{\mathsf T} N\,d\Omega , \]

intégrées aux points de Gauss, où le matériau est lu.

La matrice de Hooke \(D\), avec \(E\) le module d’Young et \(\nu\) le coefficient de Poisson :

  • plane_stress (\(\sigma_{zz} = 0\)), avec \(c = E/(1-\nu^2)\) : \(D = c\,[[1, \nu, 0], [\nu, 1, 0], [0, 0, (1-\nu)/2]]\) ;
  • plane_strain (\(\varepsilon_{zz} = 0\)), avec \(c = E/((1+\nu)(1-2\nu))\) : \(D = c\,[[1-\nu, \nu, 0], [\nu, 1-\nu, 0], [0, 0, (1-2\nu)/2]]\) ;
  • full_3d, même \(c\) : le bloc normal \(3\times3\) de \(c(1-\nu)\) sur la diagonale et \(c\nu\) ailleurs, et \(G = c\,(1-2\nu)/2\) sur les trois cisaillements ;
  • axisymmetric, même \(c\) : le bloc normal isotrope sur \([rr, zz, \theta\theta]\) et \(G\) sur \(rz\).

Axisymétrie. Un solide de révolution se maille dans son plan méridien \((r, z)\). Deux choses changent. La mesure d’intégration devient \(2\pi r\,|J|\), pour toutes les intégrales (raideur, masse, forces) ; c’est la géométrie qui l’apporte, pas la physique. Et la déformation orthoradiale \(\varepsilon_{\theta\theta} = u_r / r\) ajoute une ligne à \(B\), \([N_i/r,\; 0]\) ; elle est rangée dans eps_zz. Les points de Gauss étant intérieurs aux mailles, \(r > 0\) même pour une maille qui touche l’axe.

Dilatation thermique. Une température impose une déformation libre \(\varepsilon_{th} = \alpha\,(T - T_\text{ref})\), sans effet de la mécanique sur la thermique. La raideur ne change pas ; le terme thermique agit sur la contrainte et le second membre :

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

alpha est une composante facultative du matériau ; pyrucast.element_field.thermal_strain(temperature, materiaux, fespace, t_ref) calcule \(\varepsilon_{th}\) aux points de Gauss depuis une température portée aux points de Gauss par interp_to_gauss ; integrate_behavior puis internal_forces en tirent la charge thermique.

Variables

Isotrope, en petites déformations, selon la cinématique :

plane_stress, plane_strainaxisymmetricfull_3d
primalesu_x, u_yu_x, u_yu_x, u_y, u_z
dualesf_x, f_yf_x, f_yf_x, f_y, f_z
entrée du comportementeps_xx, eps_yy, eps_xyeps_xx, eps_yy, eps_zz, eps_xyeps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy
sortie du comportementsigma_xx, sigma_yy, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_yz, sigma_xz, sigma_xy

En axisymétrie, eps_zz et sigma_zz sont les composantes orthoradiales.

matériau requisE, nu
matériau facultatifrho, alpha
étataucun

Ce qu’elle produit

raideur\(K_e\)
masse\(M_e\), exige rho
géométrique\(\int G^{\mathsf T}\hat\sigma\,G\) sous la contrainte courante, lue dans sigma_*
tangenteanalytique : le module élastique \(D\), d’où la raideur
forces internes\(\int B^{\mathsf T}\sigma\)

La raideur géométrique est décrite avec les opérateurs d’assemblage.

Exemple

Une plaque carrée en contraintes planes, tenue en u_x sur son bord gauche et en u_y à son coin inférieur gauche, tirée par une traction \(S = 100\) sur son bord droit. La solution est exacte : \(u_x = S\,x/E\), \(u_y = -\nu\,S\,y/E\) :

gauche, droite, carre = _carre()  # un carré [0, 1]² de 2×2 QUA4
plaque = pyrucast.model.elasticity(pyrucast.FiniteElementSpace(carre), "plane_stress")
coin = gauche.node(0, 0, 0)
modele = (
    plaque
    | pyrucast.model.dirichlet(gauche.to_poi1(), plaque, "u_x")
    | pyrucast.model.dirichlet(pyrucast.mesh.poi1_from_nodes([coin]), plaque, "u_y")
    | pyrucast.model.flux(pyrucast.FiniteElementSpace(droite), plaque, "f_x")
)
materiaux = pyrucast.element_field.material_field(
    modele,
    [("E", 200e3), ("nu", 0.3), ("phi_f_x", 100.0)],  # traction S = 100 à droite
)
u = pyrucast.matrix.stiffness(modele, materiaux).solve(
    pyrucast.node_field.external_forces(modele, materiaux)
)
haut_droit = droite.node(0, 1, 1)
print(round(u.value(haut_droit, "u_x"), 12))  # 0.0005 = S/E
print(round(u.value(haut_droit, "u_y"), 12))  # -0.00015 = −ν·S/E

eps = pyrucast.element_field.strain(modele, u, materiaux)
repos = pyrucast.element_field.initial_state(modele, materiaux)
sigma = pyrucast.element_field.integrate_behavior(modele, eps, materiaux, repos)
print(sigma.components())  # ['sigma_xx', 'sigma_yy', 'sigma_xy']
print(round(sigma[0].value(0, 0, "sigma_xx"), 9))  # 100.0

Côté Rust

Elasticity (src/models/elasticity.rs) délègue sa cinématique à un Continuum (src/models/continuum/), qui porte \(B\) pour chaque hypothèse et le noyau de forces internes ; la matrice \(D\) vient de src/models/symmetry.rs, l’isotropie gardant ses formes fermées.

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::symmetry::MaterialSymmetry;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::element_field::StrainMeasure;
use pyrucast::ops::model;
use pyrucast::ops::solver::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)?);
        model::dirichlet(&imposed, target, var, 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,
        MaterialSymmetry::Isotropic,
        StrainMeasure::Linear,
    )?;
    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")?)?;

    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(())
}

Sur un solide de révolution, tests/axisymmetric.rs vérifie la solution de Lamé d’un cylindre épais : avec QUA8, l’erreur sur les déplacements nodaux décroît à l’ordre 4 environ et celle des contraintes à l’ordre 2, sans jamais s’annuler, \(1/r\) n’étant pas un polynôme.

La barre chauffée vérifie la dilatation thermique : encastrée aux deux bouts, \(\sigma_{xx} = -E\,\alpha\,\Delta T\) ; libre, une dilatation sans contrainte :

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::symmetry::MaterialSymmetry;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::element_field::{deformation, interp_to_gauss, thermal_strain, StrainMeasure};
use pyrucast::ops::model;
use pyrucast::ops::node_field::internal_forces;
use pyrucast::ops::solver::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)?);
        model::dirichlet(&imposed, target, var, 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,
        MaterialSymmetry::Isotropic,
        StrainMeasure::Linear,
    )?;
    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, StrainMeasure::Linear)?;
    let sig_th = pyrucast::ops::element_field::behavior::integrate(
        &model,
        &eps_th,
        &pyrucast::ops::element_field::behavior::initial_state(&model, &materials)?,
        &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, StrainMeasure::Linear)?;
    let eps_mech = eps.merge_field(&eps_th, |a, b| a - b)?;
    let rest = pyrucast::ops::element_field::behavior::initial_state(&model, &materials)?;
    let sigma = pyrucast::ops::element_field::behavior::integrate(
        &model, &eps_mech, &rest, &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(())
}

Élasticité orthotrope et anisotrope

En bref

Un composite tissé, un bois, une tôle laminée ou un monocristal n’ont pas la même raideur dans toutes les directions. C’est l’option symmetry de l’élasticité, indépendante de la cinématique :

symmetryconstantesce qu’elle décrit
"isotropic"2aucune direction privilégiée
"orthotropic"9trois plans de symétrie orthogonaux
"anisotropic"21le cas général

Les variables ne changent pas : seule la matrice de Hooke \(D\) change. Les lois non linéaires (plasticité, fluage, endommagement de Mazars et Damage TC) gardent une élasticité isotrope ; la loi SiC/SiC a ses propres axes.

Théorie

La loi de comportement est \(\sigma_{ij} = C_{ijkl}\,\varepsilon_{kl}\). Les symétries de \(\sigma\) et \(\varepsilon\) et l’existence d’un potentiel élastique réduisent \(C\) à une matrice \(6\times6\) symétrique en notation de Voigt : 21 constantes dans le cas général.

Orthotropie. Dans ses axes propres, la souplesse \(S = C^{-1}\) découple les termes normaux des cisaillements :

\[ S = \begin{bmatrix} 1/E_1 & -\nu_{21}/E_2 & -\nu_{31}/E_3 & & & \\ -\nu_{12}/E_1 & 1/E_2 & -\nu_{32}/E_3 & & & \\ -\nu_{13}/E_1 & -\nu_{23}/E_2 & 1/E_3 & & & \\ & & & 1/G_{23} & & \\ & & & & 1/G_{13} & \\ & & & & & 1/G_{12} \end{bmatrix}, \qquad \frac{\nu_{ji}}{E_j} = \frac{\nu_{ij}}{E_i} . \]

Les relations de réciprocité ne laissent que neuf constantes. \(S\) doit être définie positive : un jeu de constantes qui la rend singulière est refusé, plutôt que d’assembler une raideur fausse.

Les axes matériau sont donnés par des vecteurs du champ matériau : V1X, V1Y en 2-D (le second axe est la normale dans le plan, le troisième la direction hors du plan, l’orthoradiale en axisymétrie) ; V1X…V1Z et V2X…V2Z en 3-D (le troisième est \(V_1 \times V_2\)). Ils sont orthonormalisés en interne : \(V_2\) n’a besoin d’être qu’à peu près perpendiculaire à \(V_1\). Un \(V_1\) nul, ou un \(V_2\) parallèle à \(V_1\), est refusé. Comme toute composante matériau, les axes peuvent varier d’une maille et d’un point de Gauss à l’autre : un composite bobiné, une pièce courbe.

En chaque point de Gauss, \(D\) est construit dans les axes matériau, tourné dans les axes globaux par le tenseur d’ordre 4,

\[ C'_{pqrs} = R_{pi}\,R_{qj}\,R_{rk}\,R_{sl}\,C_{ijkl}, \qquad R = \big[\,V_1\ \ V_2\ \ V_1\times V_2\,\big], \]

puis réduit à la cinématique : le bloc [xx, yy, xy] en déformations planes, sa condensation sur \(\varepsilon_{zz}\) en contraintes planes, le bloc [rr, zz, θθ, rz] en axisymétrie. En cisaillement de l’ingénieur, le passage Voigt ↔ tenseur ne porte aucun facteur, et la rotation d’ordre 4 n’a aucune convention à retenir.

Variables

symmetrymatériau requis
"isotropic"E, nu
"orthotropic"E_1, E_2, E_3, nu_12, nu_13, nu_23, G_12, G_13, G_23, et les axes : V1X, V1Y en 2-D ; V1X, V1Y, V1Z, V2X, V2Y, V2Z en 3-D
"anisotropic"C_11, C_12, C_13, C_14, C_15, C_16, C_22, C_23, C_24, C_25, C_26, C_33, C_34, C_35, C_36, C_44, C_45, C_46, C_55, C_56, C_66 : le triangle supérieur dans l’ordre [xx, yy, zz, yz, xz, xy] ; et les axes : V1X, V1Y en 2-D ; V1X, V1Y, V1Z, V2X, V2Y, V2Z en 3-D

Les neuf constantes orthotropes sont demandées même en 2-D : le tenseur est construit en 3-D avant d’être réduit. Les trois jeux de composantes sont disjoints, si bien qu’une zone isotrope et une zone orthotrope partagent un maillage sans rien consolider. L’entrée et la sortie du comportement sont celles de l’élasticité isotrope.

Exemple

Les constantes d’un matériau quatre fois plus raide selon son axe 1 :

gauche, droite, carre = _carre()
plaque = pyrucast.model.elasticity(
    pyrucast.FiniteElementSpace(carre), "plane_stress", symmetry="orthotropic"
)
print(plaque[0].material_components())
# ['E_1', 'E_2', 'E_3', 'nu_12', 'nu_13', 'nu_23', 'G_12', 'G_13', 'G_23', 'V1X', 'V1Y']
angle = 0.0  # l'axe raide, selon x
constantes = [
    ("E_1", 40e3),
    ("E_2", 10e3),
    ("E_3", 10e3),
    ("nu_12", 0.25),
    ("nu_13", 0.25),
    ("nu_23", 0.3),
    ("G_12", 5e3),
    ("G_13", 5e3),
    ("G_23", 4e3),
    ("V1X", math.cos(angle)),
    ("V1Y", math.sin(angle)),
]

Sur la plaque en traction de la page Élasticité, le fichier de tests vérifie les deux bornes : \(u_x = S/E_1\) quand l’axe raide suit la traction, \(S/E_2\) quand il lui est perpendiculaire.

Côté Rust

src/models/symmetry.rs construit les tenseurs orientés pour l’élasticité, la conduction et la diffusion ; MaterialSymmetry est l’énumération des trois symétries. Le test ci-dessous étire un carré orthotrope selon son premier axe matériau ; le même fichier nourrit une loi orthotrope de constantes isotropes, puis une loi anisotrope du tenseur isotrope, qui doivent se comporter comme l’isotrope quel que soit le repère, et refuse un repère dégénéré.

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::containers::node_field::NodeField;
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::models::symmetry::MaterialSymmetry;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::element_field::StrainMeasure;
use pyrucast::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

/// Traction on the right edge.
const S: f64 = 2.0;
/// `N×N` QUA4 grid on the unit square.
const N: usize = 2;

#[test]
fn orthotropic_square_stretches_along_its_first_material_axis() -> Result<()> {
    const E1: f64 = 200.0; // stiff direction — aligned with global x
    const E2: f64 = 50.0; // compliant transverse direction
    const NU12: f64 = 0.25;

    let (grid, fes, _coords) = unit_square()?;
    let model = clamped_model(&grid, &fes, MaterialSymmetry::Orthotropic)?;

    // The material axes travel through the material field like any other
    // coefficient: `V1` is the first orthotropy direction, here the global x.
    let materials = pyrucast::ops::element_field::material_field(
        &model,
        &[
            ("E_1", E1),
            ("E_2", E2),
            ("E_3", E2),
            ("nu_12", NU12),
            ("nu_13", NU12),
            ("nu_23", 0.25),
            ("G_12", 30.0),
            ("G_13", 30.0),
            ("G_23", 30.0),
            ("V1X", 1.0),
            ("V1Y", 0.0),
            ("phi_f_x", S),
        ],
    )?;

    let solution = solve_traction(&model, &materials)?;

    // σ_xx = S with σ_yy = σ_zz = 0 ⇒ the compliance row gives ε directly.
    let tol = 1e-10;
    let h = 1.0 / N as f64;
    for j in 0..=N {
        for i in 0..=N {
            let (x, y) = (i as f64 * h, j as f64 * h);
            let id = grid[j * (N + 1) + i].id();
            let ux = solution.value(id, "u_x")?;
            let uy = solution.value(id, "u_y")?;
            assert!((ux - S / E1 * x).abs() < tol, "u_x({x},{y}) = {ux}");
            assert!((uy + NU12 * S / E1 * y).abs() < tol, "u_y({x},{y}) = {uy}");
        }
    }
    Ok(())
}

Plasticité de von Mises

En bref

Un métal qui se déforme de façon irréversible au-delà d’une limite d’élasticité. Deux lois, au critère de von Mises :

opérateurspyrucast.model.plasticity_perfect(fespace, kinematics, strain_measure="linear") (sans écrouissage), pyrucast.model.plasticity_isotropic(fespace, kinematics, strain_measure="linear") (écrouissage isotrope linéaire)
tagmechanical
typedomaine avec comportement (label() : "Plasticity")
éléments2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27
cinématiquesplane_stress, plane_strain, axisymmetric, full_3d

Théorie

L’élastoplasticité en petites déformations partage la déformation en une part élastique et une part plastique :

\[ \varepsilon = \varepsilon^e + \varepsilon^p, \qquad \sigma = D : (\varepsilon - \varepsilon^p), \qquad f(\sigma, p) \le 0, \qquad \dot\varepsilon^p = \dot\lambda\,\frac{\partial f}{\partial\sigma}, \]

avec les conditions de Kuhn-Tucker \(\dot\lambda \ge 0\), \(f \le 0\), \(\dot\lambda\,f = 0\). Le critère de von Mises porte sur la contrainte équivalente \(q = \sqrt{\tfrac32\,s : s}\), où \(s\) est le déviateur de \(\sigma\) :

\[ f(\sigma, p) = q - \big(\sigma_y + H\,p\big), \qquad \dot p = \dot\lambda , \]

\(p\) étant la déformation plastique cumulée et \(H\) le module d’écrouissage, nul pour la loi parfaite.

Le retour radial. Un pas part de l’état \(A\) (\(\varepsilon^p_A\), \(p_A\)) et de la déformation totale \(\varepsilon_B\) :

  1. prédiction élastique, \(\sigma^{tr} = D : (\varepsilon_B - \varepsilon^p_A)\), et sa contrainte équivalente \(q^{tr}\) ;
  2. si \(q^{tr} \le \sigma_y + H\,p_A\), le pas est élastique ;
  3. sinon, la normale étant colinéaire au déviateur, la consistance est affine et se résout sans itérer :

\[ \Delta p = \frac{q^{tr} - (\sigma_y + H\,p_A)}{3\mu + H}, \qquad s_B = s^{tr}\Big(1 - \frac{3\mu\,\Delta p}{q^{tr}}\Big), \qquad \varepsilon^p_B = \varepsilon^p_A + \frac{3\,\Delta p}{2\,q^{tr}}\,s^{tr}, \]

\(\mu\) étant le module de cisaillement ; la pression ne change pas.

Le calcul est mené en 3-D quelle que soit la cinématique. En déformations planes, les composantes hors du plan de la déformation sont nulles ; en contraintes planes, la condition \(\sigma_{zz} = 0\) est résolue par une itération locale autour du retour radial ; en axisymétrie, la déformation orthoradiale est mesurée.

La tangente cohérente est la dérivée exacte du retour,

\[ D_\text{alg} = K\,I\otimes I + 2\mu\,\theta\,\mathbb I_\text{dev} - 2\mu\,\bar\theta\;\hat n\otimes\hat n, \qquad \theta = \frac{\sigma_y + H\,p_B}{q^{tr}}, \qquad \bar\theta = \frac{3\mu}{3\mu + H} - (1 - \theta), \]

avec \(\hat n = s^{tr}/\lVert s^{tr}\rVert\) et \(K\) le module de compressibilité. Le facteur \(\theta\) rend compte du pas fini : c’est ce qui donne à Newton sa convergence quadratique.

La raideur reste la raideur élastique \(\int B^{\mathsf T} D\,B\) : un opérateur constant, celui d’un Newton modifié. La tangente \(\int B^{\mathsf T} D_\text{alg} B\) est celle d’un Newton complet.

Variables

Selon la cinématique :

plane_stress, plane_strainaxisymmetricfull_3d
primalesu_x, u_yu_x, u_yu_x, u_y, u_z
dualesf_x, f_yf_x, f_yf_x, f_y, f_z
entrée du comportementeps_xx, eps_yy, eps_xyeps_xx, eps_yy, eps_zz, eps_xyeps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy
contraintesigma_xx, sigma_yy, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_yz, sigma_xz, sigma_xy

En axisymétrie, eps_zz et sigma_zz sont les composantes orthoradiales.

La sortie du comportement est la contrainte, suivie de l’état :

matériau requisE, nu, sigma_y ; plus H pour plasticity_isotropic
matériau facultatifrho, alpha
état, écrit après la contraintela déformation plastique eps_p_xx, eps_p_yy, eps_p_zz, eps_p_yz, eps_p_xz, eps_p_xy ; la déformation plastique cumulée p ; la déformation totale eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy ; et, en plane_stress et plane_strain, sigma_zz
état relu (prev)toute la sortie du pas précédent : la contrainte et l’état

La déformation plastique et la déformation totale ont toujours six composantes, quelle que soit la cinématique. Au premier pas, prev est l’état de repos, element_field.initial_state(modele, materiaux), où tout est nul. Ensuite, la sortie d’un pas se passe telle quelle comme prev au pas suivant.

Ce qu’elle produit

raideurélastique
masseexige rho
géométriquesous la contrainte sigma_*
tangenteanalytique, \(D_\text{alg}\) ; demande la déformation et prev
forces internes\(\int B^{\mathsf T}\sigma\)

Exemple

Une maille étirée à \(\varepsilon_{xx} = 2\cdot10^{-3}\), au-delà de \(\sigma_y/E\). La contrainte équivalente reste à \(\sigma_y\) :

fes, u = _un_qua4(2e-3)  # une maille, ε_xx = 2·10⁻³ : au-delà de σ_y/E
modele = pyrucast.model.plasticity_perfect(fes, "plane_stress")
materiaux = pyrucast.element_field.material_field(
    modele, [("E", 210e3), ("nu", 0.3), ("sigma_y", 250.0)]
)

eps = pyrucast.element_field.strain(modele, u, materiaux)
repos = pyrucast.element_field.initial_state(
    modele, materiaux
)  # premier pas : l'état de repos
etat = pyrucast.element_field.integrate_behavior(modele, eps, materiaux, repos)
s = {nom: etat[0].value(0, 0, nom) for nom in ("sigma_xx", "sigma_yy", "sigma_xy")}
q = math.sqrt(
    s["sigma_xx"] ** 2
    + s["sigma_yy"] ** 2
    - s["sigma_xx"] * s["sigma_yy"]
    + 3 * s["sigma_xy"] ** 2
)
print(round(q, 3))  # 250.0 : la contrainte équivalente est plafonnée à σ_y
print(etat[0].value(0, 0, "p") > 0)  # True : de la déformation plastique

# Le pas suivant repart de cet état.
etat = pyrucast.element_field.integrate_behavior(modele, eps, materiaux, prev=etat)
Kt = pyrucast.matrix.tangent(
    modele, materiaux, eps, prev=etat
)  # le module algorithmique

Avec écrouissage, elle vaut \(\sigma_y + H\,p\) :

modele = pyrucast.model.plasticity_isotropic(fes, "plane_stress")
materiaux = pyrucast.element_field.material_field(
    modele, [("E", 210e3), ("nu", 0.3), ("sigma_y", 250.0), ("H", 1000.0)]
)
eps = pyrucast.element_field.strain(modele, u, materiaux)
etat = pyrucast.element_field.integrate_behavior(
    modele, eps, materiaux, pyrucast.element_field.initial_state(modele, materiaux)
)
s = {nom: etat[0].value(0, 0, nom) for nom in ("sigma_xx", "sigma_yy", "sigma_xy")}
q = math.sqrt(
    s["sigma_xx"] ** 2
    + s["sigma_yy"] ** 2
    - s["sigma_xx"] * s["sigma_yy"]
    + 3 * s["sigma_xy"] ** 2
)
print(round(q - (250.0 + 1000.0 * etat[0].value(0, 0, "p")), 3))  # 0.0 : q = σ_y + H·p

La boucle de Newton complète (pas de charge, résidu \(F_\text{ext} - F_\text{int}\), résolution, report de l’état) s’écrit en Python avec ces opérateurs ; examples/plasticite_poutre_console.py en est un exemple, une console cisaillée jusqu’à plastifier à l’encastrement.

Côté Rust

Plasticity (src/models/plasticity.rs) porte la loi d’écoulement en attribut : une même physique sert les dix lois de cette page, de la page Drucker-Prager et Ottosen, de la page Fluage et la loi de Gurson. Les deux lois de von Mises vivent dans src/models/plasticity/von_mises.rs ; H = 0 redonne la loi parfaite par le même chemin de code. examples/plasticite_poutre_console.rs est la même console en Rust, et sert de banc de parallélisme.

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::element_field::ElementField;
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::plasticity::law::PlasticLaw;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::element_field::{
    behavior::{initial_state, integrate},
    deformation, material_field, StrainMeasure,
};
use pyrucast::ops::matrix::tangent;
use pyrucast::ops::model;
use pyrucast::ops::node_field::internal_forces;
use pyrucast::Result;

const AXES: [&str; 3] = ["x", "y", "z"];

/// von Mises with linear isotropic hardening.
const ISOTROPIC: &[(&str, f64)] = &[
    ("E", 70_000.0),
    ("nu", 0.3),
    ("sigma_y", 200.0),
    ("H", 5_000.0),
];
/// A frictional, mildly dilatant material — `ψ < α`, so the flow is
/// non-associated.
const DRUCKER: &[(&str, f64)] = &[
    ("E", 20_000.0),
    ("nu", 0.2),
    ("friction", 0.3),
    ("k", 30.0),
    ("psi", 0.1),
];
/// Ottosen's classic concrete set, for a tensile/compressive strength ratio of
/// about 0.1.
const OTTOSEN: &[(&str, f64)] = &[
    ("E", 30_000.0),
    ("nu", 0.2),
    ("a", 1.2759),
    ("b", 3.1962),
    ("k_1", 11.7365),
    ("k_2", 0.9801),
    ("sigma_c", 30.0),
];

#[test]
fn isotropic_hardening_satisfies_its_consistency_condition() -> Result<()> {
    let cube = Cube::new(PlasticLaw::Isotropic, ISOTROPIC)?;
    // Well past yield, so the step is definitely plastic.
    let s = cube.stress(&uniaxial(0.02))?;
    let (q, p) = (von_mises(&s.sigma), s.p);
    assert!(p > 0.0, "the step must be plastic (p = {p})");
    let expected = 200.0 + 5_000.0 * p;
    assert!(
        (q - expected).abs() < 1e-6 * expected,
        "q = {q}, expected σ_y + H·p = {expected}"
    );
    Ok(())
}

Drucker-Prager et Ottosen

En bref

Deux lois plastiques pour les matériaux plus résistants en compression qu’en traction : sols, roches, poudres, béton. Leur surface voit la pression, que von Mises ignore.

opérateurspyrucast.model.drucker_prager(fespace, kinematics, strain_measure="linear"), pyrucast.model.ottosen(fespace, kinematics, strain_measure="linear")
tagmechanical
typedomaine avec comportement (label() : "Plasticity")
éléments2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27
cinématiquesplane_stress, plane_strain, axisymmetric, full_3d

Elles suivent le cadre de la plasticité de von Mises : partition de la déformation, critère \(f \le 0\), écoulement et conditions de Kuhn-Tucker. Elles en diffèrent par la surface \(f\), le potentiel \(g\) de l’écoulement (\(\dot\varepsilon^p = \dot\lambda\,\partial g/\partial\sigma\)), et la tangente.

Théorie

Les surfaces s’écrivent sur les invariants de la contrainte :

\[ I_1 = \operatorname{tr}\sigma, \qquad J_2 = \tfrac12\,s : s, \qquad J_3 = \det s, \qquad q = \sqrt{3J_2}, \qquad \cos 3\theta = \frac{3\sqrt3}{2}\,\frac{J_3}{J_2^{3/2}} , \]

\(\theta \in [0, \pi/3]\) étant l’angle de Lode, la direction de la contrainte dans le plan déviatorique. Von Mises ne voit que \(q\) ; Drucker-Prager ajoute \(I_1\), donc la pression ; Ottosen ajoute \(\theta\).

Drucker-Prager

Le cône

\[ f(\sigma) = \alpha\,I_1 + \beta\,q - k \]

a pour pente \(\alpha\) (friction), pour cohésion \(k\) et pour poids déviatorique \(\beta\) (beta, 1 par défaut). L’écoulement n’est pas associé : le potentiel a sa propre pente, la dilatance \(\psi\) (psi), et son propre poids déviatorique \(\delta\) (delta, 1 par défaut), \(g(\sigma) = \psi\,I_1 + \delta\,q\). \(\psi = \alpha\) redonne un écoulement associé, \(\psi = 0\) un écoulement sans variation de volume.

Sur le flanc du cône, \(q\) et \(I_1\) sont affines en le multiplicateur, et le retour se résout sans itérer. Un retour qui donnerait \(q_B < 0\) a dépassé l’axe hydrostatique : la contrainte se pose alors au sommet du cône, \(s_B = 0\), \(\sigma_{m,B} = k/(3\alpha)\), où la tangente est nulle : un corps entièrement à son sommet ne porte plus rien.

Écrouissage. La cohésion croît avec la déformation plastique cumulée, \(dk = H\,dp\) (H, 0 par défaut ; négatif, il adoucit), jusqu’à une surface ultime \(\alpha_u I_1 + \beta_u q = k_u\) (friction_ult, beta_ult, k_ult, par défaut égales aux valeurs initiales). La surface courante interpole les deux :

\[ \lambda = \operatorname{clamp}\!\Big(\frac{H\,p}{k_u - k}, 0, 1\Big), \qquad (\alpha, \beta, k)(p) = (1-\lambda)\,(\alpha, \beta, k) + \lambda\,(\alpha_u, \beta_u, k_u), \]

si bien que \(k(p) = k + H\,p\) tant que la surface ultime n’est pas atteinte. La consistance n’est alors plus linéaire : elle est résolue par un Newton encadré, dont la solution sans écrouissage est le premier itéré. Sans écrouissage, le retour reste fermé.

Ottosen

Une surface à quatre paramètres, faite pour le béton :

\[ f(\sigma) = a\,\frac{J_2}{\sigma_c^2} + \lambda(\theta)\,\frac{\sqrt{J_2}}{\sigma_c} + b\,\frac{I_1}{\sigma_c} - 1, \]

\[ \lambda(\theta) = \begin{cases} k_1 \cos\!\big[\tfrac13 \arccos(k_2 \cos 3\theta)\big] & \text{si } \cos 3\theta \ge 0,\\ k_1 \cos\!\big[\tfrac\pi3 - \tfrac13 \arccos(-k_2 \cos 3\theta)\big] & \text{si } \cos 3\theta < 0 . \end{cases} \]

\(\sigma_c\) est la résistance en compression simple. Le terme en \(J_2\) courbe les méridiens, le terme en \(I_1\) les incline, et \(\lambda(\theta)\) fait de la section déviatorique un triangle arrondi qui s’ouvre vers la compression.

Aucun retour fermé n’existe. Le retour passe par l’algorithme du plan sécant, qui n’a besoin que de \(f\) : partant du prédicteur, on linéarise le critère à l’itéré courant, on en tire un multiplicateur, on corrige, jusqu’à \(|f|\) sous une tolérance. La normale \(\partial f/\partial\sigma\) est obtenue par différences centrées sur \(f\), avec un pas de \(10^{-6}\,\sigma_c\).

La tangente par perturbation

Les deux lois obtiennent leur tangente par différences centrées sur le retour lui-même, une colonne par composante de déformation :

\[ D_{ij} \simeq \frac{\sigma_i(\varepsilon + h\,e_j) - \sigma_i(\varepsilon - h\,e_j)}{2h}, \qquad h = 10^{-6}\,\lVert\varepsilon\rVert_\infty , \]

soit douze retours par point de Gauss. Les colonnes de cisaillement sont divisées par deux, pour passer de \(\partial\sigma/\partial\varepsilon_{ij}\) à \(\partial\sigma/\partial\gamma_{ij}\).

La tangente est symétrisée : seul son triangle supérieur est gardé. Celle d’un écoulement non associé, comme Drucker-Prager, ne l’est pas ; la symétriser coûte à Newton sa convergence quadratique sur cette loi. Celle d’Ottosen est doublement numérique (la normale, puis la tangente) : moins précise, elle suffit à faire converger Newton.

Variables

Selon la cinématique :

plane_stress, plane_strainaxisymmetricfull_3d
primalesu_x, u_yu_x, u_yu_x, u_y, u_z
dualesf_x, f_yf_x, f_yf_x, f_y, f_z
entrée du comportementeps_xx, eps_yy, eps_xyeps_xx, eps_yy, eps_zz, eps_xyeps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy
contraintesigma_xx, sigma_yy, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_yz, sigma_xz, sigma_xy

En axisymétrie, eps_zz et sigma_zz sont les composantes orthoradiales.

La sortie du comportement est la contrainte, suivie de l’état :

matériau requisDrucker-Prager : E, nu, friction, k, psi ; Ottosen : E, nu, a, b, k_1, k_2, sigma_c
matériau facultatifrho, alpha ; Drucker-Prager : beta, delta, H, friction_ult, beta_ult, k_ult
état, écrit après la contraintela déformation plastique eps_p_xx, eps_p_yy, eps_p_zz, eps_p_yz, eps_p_xz, eps_p_xy ; la déformation plastique cumulée p ; la déformation totale eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy ; et, en plane_stress et plane_strain, sigma_zz
état relu (prev)toute la sortie du pas précédent : la contrainte et l’état

Ce qu’elles produisent

raideurélastique
masseexige rho
géométriquesous la contrainte sigma_*
tangentepar perturbation, symétrisée
forces internes\(\int B^{\mathsf T}\sigma\)

Exemple

La tangente de Drucker-Prager, assemblée sur une maille plastifiée :

modele = pyrucast.model.drucker_prager(fes, "plane_strain")
materiaux = pyrucast.element_field.material_field(
    modele, [("E", 20e3), ("nu", 0.2), ("friction", 0.3), ("k", 10.0), ("psi", 0.1)]
)
eps = pyrucast.element_field.strain(modele, u, materiaux)
repos = pyrucast.element_field.initial_state(modele, materiaux)
etat = pyrucast.element_field.integrate_behavior(modele, eps, materiaux, repos)
Kt = pyrucast.matrix.tangent(
    modele, materiaux, eps, repos
)  # par perturbation, symétrisée
print(Kt.symmetric)  # True

Côté Rust

Les deux lois vivent dans src/models/plasticity/drucker_prager.rs et src/models/plasticity/ottosen.rs, et déclarent leur tangente par TangentSource::Numerical. tests/plastic_laws.rs vérifie que le retour atterrit sur la surface, l’effondrement au sommet du cône, l’écart entre traction et compression d’Ottosen, et confronte chaque tangente à une différence finie des forces internes.

Fluage et viscoplasticité

En bref

Des lois où l’écoulement prend du temps : la contrainte peut sortir du domaine élastique, et c’est l’écart qui fixe la vitesse à laquelle elle y revient. Le résultat d’un pas dépend de sa durée.

opérateurspyrucast.model.creep_norton(fespace, kinematics, strain_measure="linear"), pyrucast.model.creep_lemaitre(fespace, kinematics, strain_measure="linear"), pyrucast.model.creep_blackburn(fespace, kinematics, strain_measure="linear"), pyrucast.model.viscoplasticity_chaboche(fespace, kinematics, strain_measure="linear"), pyrucast.model.viscoplasticity_lemaitre_chaboche(fespace, kinematics, strain_measure="linear")
tagmechanical
typedomaine avec comportement (label() : "Plasticity")
éléments2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27
cinématiquesplane_stress, plane_strain, axisymmetric, full_3d

integrate_behavior et matrix.tangent exigent le pas de temps dt pour ces lois, et lèvent une erreur sans lui : intégrer une loi visqueuse comme si elle était instantanée donnerait un nombre plausible et faux.

loivitessece qu’elle décrit
creep_norton\(\dot p = (q/K)^n\)fluage secondaire, stationnaire
creep_lemaitre\(\dot p = (q/K)^N\,p^{-M}\)fluage primaire, par écrouissage en déformation
creep_blackburnprimaire saturant et secondaire, en sinhles deux stades
viscoplasticity_chaboche\(\dot p = \langle (J(\sigma - X) - R - k)/K\rangle^n\)viscoplasticité cyclique
viscoplasticity_lemaitre_chabochela même, sur \(\sigma/(1-D)\)et endommagement ductile

Théorie

Le cadre est celui de la plasticité, sans les conditions de Kuhn-Tucker : la vitesse d’écoulement n’est plus l’inconnue d’une condition de consistance, elle est donnée par une loi,

\[ \dot\varepsilon^{vp} = \dot p\,\frac32\,\frac{s}{q}, \qquad \dot p = g(q, p, \dots). \]

L’écoulement est déviatorique, sans variation de volume, et radial.

Un solveur pour toutes. Le pas est intégré par Euler implicite : la vitesse est évaluée en fin de pas. La direction étant celle du prédicteur, le déviateur ne fait que se contracter, \(q(\Delta p) = q^{tr} - 3\mu\,\Delta p\), et le pas se ramène à une équation scalaire,

\[ R(\Delta p) = \Delta p - \Delta t\,g\big(q(\Delta p), p_A + \Delta p, \dots\big) = 0 , \]

dont la racine est unique dans \([0, q^{tr}/3\mu]\). Les lois de vitesse étant raides, elle est cherchée par un Newton encadré, doublé d’une dichotomie.

Norton. \(\dot p = (q/K)^n\), sans seuil : toute contrainte flue. \(K\) est la contrainte de référence, \(n\) la sensibilité.

Lemaitre. \(\dot p = (q/K)^N\,p^{-M}\), \(M > 0\) : la déformation accumulée ralentit l’écoulement ; à contrainte constante, \(p(t) \propto t^{1/(1+M)}\). \(p\) a un plancher minuscule, pour que \(p^{-M}\) reste fini au premier pas.

Blackburn. La déformation primaire est une variable à part, p_prim :

\[ \dot p_\text{prim} = r\,\big(\varepsilon_\infty(q) - p_\text{prim}\big), \qquad \varepsilon_\infty(q) = A\,\sinh(\alpha q), \qquad \dot p = \dot p_\text{prim} + B\,\sinh(\beta q) . \]

Le primaire tend vers son asymptote à la vitesse \(r\) puis s’éteint ; le secondaire persiste. En Euler implicite, le primaire s’inverse en un pas, \(p^B_\text{prim} = (p^A_\text{prim} + \Delta t\,r\,A\,\sinh(\alpha q))/(1 + \Delta t\,r)\).

Chaboche. Une surface translatée par une contrainte de rappel \(X\) et agrandie par un écrouissage isotrope \(R\) :

\[ f = J(\sigma - X) - R - k, \qquad \dot p = \Big\langle \frac fK \Big\rangle^n, \qquad \dot X = \tfrac23 C\,\dot\varepsilon^{vp} - \gamma\,X\,\dot p, \qquad \dot R = b\,(Q - R)\,\dot p , \]

où \(\langle\cdot\rangle\) est la partie positive : sous la surface, le comportement est élastique. \(X\) produit l’effet Bauschinger et sature à \(C/\gamma\) ; \(R\) sature à \(Q\). La direction est gelée au prédicteur, et les deux variables sont implicites en \(\Delta p\) : \(X_B = (X_A + \tfrac23 C\,\Delta p\,\hat n)/(1 + \gamma\,\Delta p)\), \(R_B = (R_A + b\,Q\,\Delta p)/(1 + b\,\Delta p)\).

Lemaitre-Chaboche remplace la contrainte par la contrainte effective \(\tilde\sigma = \sigma/(1 - D)\), et fait croître l’endommagement avec l’écoulement :

\[ \dot D = \Big(\frac YS\Big)^s \dot p, \qquad Y = \frac{\tilde\sigma_\text{eq}^2}{2E} . \]

Un matériau endommagé flue plus vite, ce qui l’endommage davantage : c’est le fluage tertiaire. \(D\) ne décroît jamais et s’arrête à D_c.

La tangente est obtenue par perturbation et symétrisée, comme celle de Drucker-Prager.

Variables

Selon la cinématique :

plane_stress, plane_strainaxisymmetricfull_3d
primalesu_x, u_yu_x, u_yu_x, u_y, u_z
dualesf_x, f_yf_x, f_yf_x, f_y, f_z
entrée du comportementeps_xx, eps_yy, eps_xyeps_xx, eps_yy, eps_zz, eps_xyeps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy
contraintesigma_xx, sigma_yy, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_yz, sigma_xz, sigma_xy

En axisymétrie, eps_zz et sigma_zz sont les composantes orthoradiales.

La sortie du comportement est la contrainte, suivie d’un état commun aux cinq lois, puis de l’état propre à chacune :

matériau facultatifrho, alpha
état communla déformation plastique eps_p_xx, eps_p_yy, eps_p_zz, eps_p_yz, eps_p_xz, eps_p_xy ; la déformation plastique cumulée p ; la déformation totale eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy ; et, en plane_stress et plane_strain, sigma_zz
état relu (prev)toute la sortie du pas précédent
loimatériau requisétat propre, après l’état commun
creep_nortonE, nu, K, naucun
creep_lemaitreE, nu, K, N, Maucun
creep_blackburnE, nu, A_1, alpha_1, r_1, B_s, beta_sp_prim
viscoplasticity_chabocheE, nu, k, K, n, C_1, gamma_1, b, QX_xx, X_yy, X_zz, X_yz, X_xz, X_xy, R
viscoplasticity_lemaitre_chabocheE, nu, k, K, n, C_1, gamma_1, b, Q, S, s, D_cX_xx, X_yy, X_zz, X_yz, X_xz, X_xy, R, damage

Ce qu’elles produisent

raideurélastique
masseexige rho
géométriquesous la contrainte sigma_*
tangentepar perturbation, symétrisée ; exige dt
forces internes\(\int B^{\mathsf T}\sigma\)

Exemple

Une déformation maintenue sous Norton : sans dt, l’intégration est refusée ; avec, la contrainte se relaxe d’un pas à l’autre.

modele = pyrucast.model.creep_norton(fes, "plane_strain")
materiaux = pyrucast.element_field.material_field(
    modele, [("E", 150e3), ("nu", 0.3), ("K", 400.0), ("n", 5.0)]
)
eps = pyrucast.element_field.strain(modele, u, materiaux)  # déformation maintenue
repos = pyrucast.element_field.initial_state(modele, materiaux)
try:
    pyrucast.element_field.integrate_behavior(modele, eps, materiaux, repos)
except RuntimeError as erreur:
    print(erreur)  # Plasticity: this law is rate-dependent and needs a time increment…

etat = pyrucast.element_field.integrate_behavior(modele, eps, materiaux, repos, dt=1.0)
sigma_1 = etat[0].value(0, 0, "sigma_xx")
etat = pyrucast.element_field.integrate_behavior(modele, eps, materiaux, etat, dt=1.0)
print(etat[0].value(0, 0, "sigma_xx") < sigma_1)  # True : la contrainte se relaxe

Les paramètres sont des composantes du matériau. Une dépendance à la température se calcule par une évolution appliquée au champ de température.

Côté Rust

Les cinq lois vivent dans src/models/plasticity/viscous.rs, et déclarent requires_dt. Le test vérifie que Norton suit sa loi de vitesse sur un pas court, qu’une déformation maintenue se relaxe d’autant plus que le pas est long, que le primaire de Blackburn sature, que la contrainte de rappel de Chaboche plafonne sous \(C/\gamma\), que l’endommagement ne dépasse pas \(D_c\), et que toutes refusent d’intégrer sans dt.

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::element_field::ElementField;
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::plasticity::law::PlasticLaw;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::element_field::{
    behavior::{initial_state, integrate},
    deformation, material_field, StrainMeasure,
};
use pyrucast::ops::model;
use pyrucast::Result;

const AXES: [&str; 3] = ["x", "y", "z"];

/// Norton: a steady creep with a strongly non-linear stress dependence.
const NORTON: &[(&str, f64)] = &[("E", 150_000.0), ("nu", 0.3), ("K", 400.0), ("n", 5.0)];

#[test]
fn norton_creep_follows_its_rate_law() -> Result<()> {
    let cube = Cube::new(PlasticLaw::CreepNorton, NORTON)?;
    // A step short enough that the stress barely relaxes, so the closed-form
    // rate at the trial stress is the right comparison.
    let dt = 1e-6;
    let state = cube.step(&uniaxial(2e-3), &cube.rest()?, dt)?;
    let q = von_mises(&state.sigma);
    let expected = dt * (q / 400.0_f64).powf(5.0);
    assert!(
        (state.p - expected).abs() < 1e-3 * expected,
        "Δp = {}, expected dt·(q/K)^n = {expected}",
        state.p
    );
    Ok(())
}

Endommagement de Mazars

En bref

Le béton qui se fissure : il perd de sa raideur, sans déformation permanente. Un seul scalaire d’endommagement \(D \in [0, 1)\) affaiblit la contrainte.

opérateurpyrucast.model.mazars(fespace, kinematics, strain_measure="linear")
tagmechanical
typedomaine avec comportement (label() : "Damage")
éléments2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27
cinématiquesplane_stress, plane_strain, axisymmetric, full_3d

Théorie

La loi est sécante : la contrainte est la contrainte élastique affaiblie,

\[ \sigma = (1 - D)\,D_\text{el} : \varepsilon . \]

L’endommagement est piloté par la déformation équivalente, bâtie sur les parts positives des déformations principales,

\[ \tilde\varepsilon = \sqrt{\textstyle\sum_I \langle\varepsilon_I\rangle_+^2}, \qquad \kappa = \max\big(\varepsilon_{d0},\ \max_t \tilde\varepsilon\big), \]

où \(\varepsilon_{d0}\) est le seuil. \(\kappa\) ne décroît jamais : le matériau ne guérit pas. L’endommagement mêle une branche de traction et une branche de compression,

\[ D_t = 1 - \frac{\varepsilon_{d0}(1 - A_t)}{\kappa} - \frac{A_t}{e^{B_t(\kappa - \varepsilon_{d0})}}, \qquad D = \alpha_t\,D_t + \alpha_c\,D_c , \]

\(D_c\) s’écrivant de même avec \(A_c\) et \(B_c\). Les poids \(\alpha_t\), \(\alpha_c\) viennent du partage de la contrainte effective \(D_\text{el} : \varepsilon\) en parts positive et négative, sur ses valeurs principales.

Le calcul est mené sur le tenseur 3-D : en déformations planes, \(\varepsilon_{zz} = 0\) ; en contraintes planes, \(\varepsilon_{zz} = -\nu\,(\varepsilon_{xx} + \varepsilon_{yy})/(1 - \nu)\) ; en axisymétrie, l’orthoradiale est mesurée.

La raideur est la raideur élastique non endommagée : l’opérateur d’itération d’un Newton modifié. La loi n’a pas de tangente.

Variables

Selon la cinématique :

plane_stress, plane_strainaxisymmetricfull_3d
primalesu_x, u_yu_x, u_yu_x, u_y, u_z
dualesf_x, f_yf_x, f_yf_x, f_y, f_z
entrée du comportementeps_xx, eps_yy, eps_xyeps_xx, eps_yy, eps_zz, eps_xyeps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy
contraintesigma_xx, sigma_yy, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_yz, sigma_xz, sigma_xy

En axisymétrie, eps_zz et sigma_zz sont les composantes orthoradiales.

La sortie du comportement est la contrainte, suivie de l’état :

matériau requisE, nu, eps_d0, A_t, B_t, A_c, B_c
matériau facultatifrho, alpha
état, écrit après la contraintedamage, kappa
état relu (prev)kappa

La contrainte ne dépend que de la déformation totale et de \(\kappa\) : il n’y a pas d’incrément. Au premier pas, prev est l’état de repos, element_field.initial_state(modele, materiaux), où kappa vaut 0 : la loi prenant le maximum avec le seuil, cela revient à \(\kappa = \varepsilon_{d0}\).

Ce qu’elle produit

raideurélastique, non endommagée
masseexige rho
géométriquesous la contrainte sigma_*
tangenteaucune : matrix.tangent lève une erreur
forces internes\(\int B^{\mathsf T}\sigma\), la contrainte endommagée

Exemple

Une maille étirée à deux fois le seuil :

fes, u = _un_qua4(2e-4)  # ε_xx = 2·10⁻⁴, au-delà du seuil ε_d0 = 10⁻⁴
modele = pyrucast.model.mazars(fes, "plane_stress")
materiaux = pyrucast.element_field.material_field(
    modele,
    [
        ("E", 30e3),
        ("nu", 0.2),
        ("eps_d0", 1e-4),
        ("A_t", 0.8),
        ("B_t", 20e3),
        ("A_c", 1.4),
        ("B_c", 1900.0),
    ],
)
eps = pyrucast.element_field.strain(modele, u, materiaux)
etat = pyrucast.element_field.integrate_behavior(
    modele, eps, materiaux, pyrucast.element_field.initial_state(modele, materiaux)
)
print(
    round(etat[0].value(0, 0, "kappa"), 6)
)  # 0.0002 : la plus grande déformation équivalente atteinte
print(0.0 < etat[0].value(0, 0, "damage") < 1.0)  # True

Côté Rust

Damage (src/models/damage.rs) porte sa loi en attribut (DamageLaw), comme Plasticity : Mazars, Damage TC et SiC/SiC. Mazars vit dans src/models/damage/mazars.rs ; la décomposition spectrale passe par nalgebra. tests/damage_laws.rs vérifie que Mazars ne rend qu’un endommagement scalaire.

Damage TC, SiC/SiC et Gurson

En bref

Trois lois où un matériau perd sa raideur en se dégradant :

opérateurspyrucast.model.damage_tc(fespace, kinematics, strain_measure="linear"), pyrucast.model.damage_sic_sic(fespace, kinematics, strain_measure="linear"), pyrucast.model.gurson(fespace, kinematics, strain_measure="linear")
tagmechanical
typedomaine avec comportement (label() : "Damage" ; "Plasticity" pour Gurson)
éléments2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27
cinématiquesplane_stress, plane_strain, axisymmetric, full_3d
loivariables d’endommagementce qu’elle décrit
damage_tc\(d^+\), \(d^-\)béton, traction et compression séparées
damage_sic_sic\(d_1\), \(d_2\), \(d_3\)composite tissé, un endommagement par direction
gursonla porosité \(f\)métal ductile, rupture par croissance de cavités

Damage TC et SiC/SiC sont des lois d’endommagement, comme Mazars ; Gurson est une loi plastique.

Théorie

Le cadre de l’endommagement. La contrainte effective \(\tilde\sigma = D_\text{el} : \varepsilon\) est celle du matériau sain sous la même déformation ; la contrainte est sa version dégradée. Un moteur scalaire \(\tau\), construit sur \(\varepsilon\) ou \(\tilde\sigma\), alimente une variable d’histoire \(r = \max(r_0, \max_t \tau)\), qui ne décroît jamais, et l’endommagement est une fonction croissante de \(r\). Il n’y a pas de déformation permanente, et la mise à jour se fait en un pas, sans itérer.

Damage TC

Les parts positive et négative de la contrainte effective, découpées sur ses valeurs principales, sont dégradées chacune par sa variable :

\[ \sigma = (1 - d^+)\,\tilde\sigma^+ + (1 - d^-)\,\tilde\sigma^- . \]

Une fissure ouverte en traction qui se referme reprend donc toute la raideur en compression : c’est l’effet unilatéral, que le scalaire de Mazars ne peut pas rendre. Chaque variable a son moteur et son histoire :

\[ \tau^+ = \sqrt{\tilde\sigma^+ : \varepsilon}, \qquad \tau^- = \sqrt{\sqrt3\,\big|K\,\tilde\sigma^-_\text{oct} + \tilde\tau^-_\text{oct}\big|}, \qquad r_0^+ = \frac{f_t}{\sqrt E}, \quad r_0^- = \frac{f_c}{\sqrt E}, \]

l’énergie élastique de la part positive en traction, une mesure octaédrique (contrainte normale \(\tfrac13\operatorname{tr}\tilde\sigma^-\) et cisaillement) sensible au confinement en compression, avec \(K = 0{,}171\). L’adoucissement est exponentiel en traction, durcissant puis adoucissant en compression :

\[ d^+ = 1 - \frac{r_0^+}{r^+}\,e^{A_t(1 - r^+/r_0^+)}, \qquad d^- = 1 - \frac{r_0^-}{r^-}(1 - A_c) - A_c\,e^{2(1 - r^-/r_0^-)} . \]

SiC/SiC

Dans un composite tissé, la matrice fissure dans des plans normaux aux torons, et les fibres continuent de porter la charge : la raideur chute par direction. Chaque direction matériau \(i\) a son endommagement, piloté par la déformation dans les axes matériau \(\varepsilon^\text{mat} = R^{\mathsf T}\varepsilon R\) :

\[ \kappa_i = \max_t \langle\varepsilon^\text{mat}_{ii}\rangle_+, \qquad d_i = d_{\max,i}\Big(1 - e^{-(\kappa_i - \varepsilon_{0,i})/\varepsilon_{c,i}}\Big) \ \text{si } \kappa_i > \varepsilon_{0,i} . \]

Une direction comprimée n’est pas dégradée. La dégradation s’applique terme à terme dans les axes matériau, \(\sigma^\text{mat}_{ij} = \sqrt{1-d_i}\sqrt{1-d_j}\;\tilde\sigma^\text{mat}_{ij}\), ce qui garde l’opérateur symétrique. Chaque \(d_i\) sature à \(d_{\max,i}\) au lieu d’atteindre 1 : les fibres restent. Les axes sont ceux de l’orthotropie, les vecteurs V1 (et V2 en 3-D) du matériau ; l’élasticité, elle, est isotrope.

Gurson

La porosité \(f\) rétrécit la surface de charge :

\[ \Phi = \Big(\frac{q}{\sigma_y}\Big)^2 + 2\,q_1 f^* \cosh\!\Big(\frac{3\,q_2\,\sigma_m}{2\,\sigma_y}\Big) - \big(1 + q_3\,f^{*2}\big), \]

qui redonne von Mises à \(f = 0\). Le cosh rend la surface sensible à la pression : les cavités croissent en traction triaxiale. Au-delà d’une porosité critique \(f_c\), une porosité effective \(f^*\), bilinéaire en \(f\), accélère la coalescence et atteint \(1/q_1\) à la porosité de rupture \(f_f\). La porosité croît par conservation de la masse, \(\dot f = (1 - f)\,\operatorname{tr}\dot\varepsilon^p\), à partir de sa valeur initiale \(f_0\) ; la germination n’est pas modélisée. L’écoulement n’est donc pas isochore. Le retour se fait par plan sécant à normale numérique, comme celui d’Ottosen, et la tangente par perturbation.

Variables

Selon la cinématique :

plane_stress, plane_strainaxisymmetricfull_3d
primalesu_x, u_yu_x, u_yu_x, u_y, u_z
dualesf_x, f_yf_x, f_yf_x, f_y, f_z
entrée du comportementeps_xx, eps_yy, eps_xyeps_xx, eps_yy, eps_zz, eps_xyeps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy
contraintesigma_xx, sigma_yy, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_xysigma_xx, sigma_yy, sigma_zz, sigma_yz, sigma_xz, sigma_xy

En axisymétrie, eps_zz et sigma_zz sont les composantes orthoradiales.

La sortie du comportement est la contrainte, suivie de l’état. Matériau facultatif pour les trois lois : rho, alpha.

loimatériau requisétat, écrit après la contrainteétat relu (prev)
damage_tcE, nu, f_t, f_c, A_t, A_cdamage, r_plus, r_minus, d_plus, d_minusr_plus, r_minus, d_plus, d_minus
damage_sic_sicE, nu, eps_0_1, eps_c_1, d_max_1, eps_0_2, eps_c_2, d_max_2, eps_0_3, eps_c_3, d_max_3, et les axes : V1X, V1Y en plan et en axisymétrie ; V1X, V1Y, V1Z, V2X, V2Y, V2Z en 3-Ddamage, kappa_1, kappa_2, kappa_3, d_1, d_2, d_3kappa_1, kappa_2, kappa_3, d_1, d_2, d_3
gursonE, nu, sigma_y, q_1, q_2, q_3, f_0, f_c, f_fla déformation plastique eps_p_xx, eps_p_yy, eps_p_zz, eps_p_yz, eps_p_xz, eps_p_xy ; la déformation plastique cumulée p ; la déformation totale eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy ; et, en plane_stress et plane_strain, sigma_zz ; puis porositytoute la sortie du pas précédent

Ce qu’elles produisent

raideurélastique, non endommagée
masseexige rho
géométriquesous la contrainte sigma_*
tangenteaucune pour Damage TC et SiC/SiC (matrix.tangent lève une erreur) ; par perturbation pour Gurson
forces internes\(\int B^{\mathsf T}\sigma\)

Exemple

Une maille en traction sous Damage TC : seule la variable de traction croît.

modele = pyrucast.model.damage_tc(fes, "plane_stress")
materiaux = pyrucast.element_field.material_field(
    modele,
    [("E", 30e3), ("nu", 0.2), ("f_t", 3.0), ("f_c", 30.0), ("A_t", 0.9), ("A_c", 0.5)],
)
eps = pyrucast.element_field.strain(modele, u, materiaux)
etat = pyrucast.element_field.integrate_behavior(
    modele, eps, materiaux, pyrucast.element_field.initial_state(modele, materiaux)
)
print(
    etat[0].value(0, 0, "d_plus") > 0, etat[0].value(0, 0, "d_minus")
)  # True 0.0 : traction seule

Côté Rust

Damage TC et SiC/SiC vivent dans src/models/damage/, Gurson dans src/models/plasticity/gurson.rs. Une loi déclare l’état qu’elle relit. L’état de repos que rend initial_state est nul, sauf la porosité de Gurson : la loi déclare qu’elle part de f_0 (initial_internal_sources), et l’état de repos la lit dans le matériau.

tests/damage_laws.rs vérifie qu’une fissure refermée reprend la charge en compression, que l’écrasement laisse l’endommagement de traction intact, qu’étirer un axe de tissage n’endommage que lui et sature à son plafond, que la porosité de Gurson croît en traction triaxiale et qu’un métal poreux plastifie avant un métal dense.

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::element_field::ElementField;
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::damage::law::DamageLaw;
use pyrucast::models::plasticity::law::PlasticLaw;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::element_field::{
    behavior::{initial_state, integrate},
    deformation, material_field, StrainMeasure,
};
use pyrucast::ops::model;
use pyrucast::Result;

const AXES: [&str; 3] = ["x", "y", "z"];

const TC: &[(&str, f64)] = &[
    ("E", 30_000.0),
    ("nu", 0.2),
    ("f_t", 3.0),
    ("f_c", 30.0),
    ("A_t", 0.9),
    ("A_c", 0.5),
];

#[test]
fn a_closed_crack_carries_load_again() -> Result<()> {
    let cube = Cube::damage(DamageLaw::DamageTc, TC)?;
    // Stretch far enough to damage in tension…
    let damaged = cube.step(&uniaxial(1.5e-3), &cube.rest()?)?;
    let d_plus = damaged.var("d_plus")?;
    let d_minus = damaged.var("d_minus")?;
    assert!(d_plus > 0.1, "tension must damage (d⁺ = {d_plus})");
    // …and the **compressive** damage is untouched: the crack has not crushed
    // anything. That separation is the whole point of two variables.
    assert!(
        d_minus < 1e-12,
        "compression must stay intact (d⁻ = {d_minus})"
    );

    // Now close the crack. The compressive stiffness is the undamaged one.
    let closed = cube.step(&uniaxial(-1e-4), &damaged.field)?;
    let intact = cube.step(&uniaxial(-1e-4), &cube.rest()?)?;
    assert!(
        (closed.sigma[0] - intact.sigma[0]).abs() < 1e-9 * intact.sigma[0].abs(),
        "a closed crack must carry the full compressive stress: {} vs {}",
        closed.sigma[0],
        intact.sigma[0]
    );
    Ok(())
}

Poutre d’Euler-Bernoulli

En bref

La théorie classique des poutres : les sections restent planes et normales à l’axe déformé, si bien que la rotation de la section est la pente, et qu’il n’y a aucun cisaillement transverse.

opérateurpyrucast.model.bernoulli(fespace)
tagmechanical
typedomaine avec comportement (label() : "Bernoulli")
élémentsSEG2, sur un espace FiniteElementSpace(maillage, interpolation="HERMITE3")

La configuration se lit sur la dimension des Coords :

Coordsinconnues par nœudce qui s’ajoute à la flexion
1-Dw, thetarien : flexion pure
2-Du_x, u_y, r_zl’effort axial, et la rotation vers les axes globaux : un portique plan
3-Du_x, u_y, u_z, r_x, r_y, r_zl’axial, la torsion et la flexion selon deux axes principaux : un cadre spatial

Théorie

La section rigide tourne avec la pente :

\[ u_x(x, y) = u(x) - y\,w'(x), \qquad u_y = w(x), \qquad \theta = w' , \]

d’où une déformation axiale affine dans la section, \(\varepsilon_{xx} = \varepsilon_0 - y\,\chi\), de courbure \(\chi = w''\), et aucune distorsion. Intégrée sur la section, la loi de Hooke donne les efforts de section,

\[ N = E A\,\varepsilon_0, \qquad M = E I\,\chi , \]

et l’équilibre deux équations découplées :

\[ (E A\,u')' + n = 0, \qquad (E I\,w'')'' = q . \]

La seconde est du quatrième ordre : sa forme faible demande une interpolation \(C^1\), le déplacement et sa pente continus d’un élément au suivant. C’est ce que donne l’interpolation HERMITE3, qui prend la flèche et la pente à chaque extrémité. Un espace de Lagrange est refusé : sa flèche linéaire aurait une courbure nulle.

La courbure est linéaire sur l’élément : il est exact aux nœuds pour toute travée chargée à ses seules extrémités, et un élément par barre suffit. La raideur de flexion \(\int_0^L E I\,N''^{\mathsf T} N''\) est intégrée depuis la base et vaut la forme fermée classique,

\[ K_b = \frac{E I}{L^3} \begin{bmatrix} 12 & 6L & -12 & 6L \\ 6L & 4L^2 & -6L & 2L^2 \\ -12 & -6L & 12 & -6L \\ 6L & 2L^2 & -6L & 4L^2 \end{bmatrix}, \qquad [\,w_A,\ \theta_A,\ w_B,\ \theta_B\,]. \]

L’effort axial s’y ajoute par le terme de barre \(E A/L\), sans couplage. En 3-D, la flexion selon le second axe principal (\(M_z = E I_z\,\chi_z\)) et la torsion de Saint-Venant (\(T = G J\,\varphi'\)) s’y ajoutent ; le repère local est déduit d’une référence globale Z (ou Y pour une barre presque verticale), sans donnée d’orientation, ce qui convient aux sections symétriques.

Bernoulli est la limite sans cisaillement de Timoshenko. Une physique à part évite d’y arriver par \(G A_s \to \infty\), où le terme de cisaillement noierait la flexion en virgule flottante, et évite de demander G et A_s, que cette théorie n’utilise pas.

Variables

1-D2-D3-D
primalesw, thetau_x, u_y, r_zu_x, u_y, u_z, r_x, r_y, r_z
dualesf_w, m_thetaf_x, f_y, m_zf_x, f_y, f_z, m_x, m_y, m_z
matériau requisE, IE, A, IE, A, I_y, I_z, J, G
matériau facultatifA, rhorhorho
entrée du comportementkappaeps, kappaeps, kappa_y, kappa_z, torsion
sortie du comportementMN, MN, M_y, M_z, T
étataucunaucunaucun

L’entrée vient de pyrucast.element_field.beam_deformation, ou de strain.

Ce qu’elle produit

raideurflexion, et axial et torsion selon la configuration
masseexige rho (et A en 1-D) ; le bloc de Timoshenko pris à \(\Phi = 0\)
géométriquesous l’effort N ; aucune en 1-D, sans effort axial
tangente= raideur
forces internesle transposé du même \(B\) que la raideur : \(\int B^{\mathsf T}\sigma = K\,u\) exactement

Exemple

Une console de longueur 2 dans le plan, encastrée, chargée en bout. La flèche est exacte, \(F\,L^3/(3 E I)\) :

# Une console de longueur 2 dans le plan, encastrée à gauche, F = −10 en y au bout.
c = pyrucast.Coords(2)
a = c.add_node([0.0, 0.0])
b = c.add_node([2.0, 0.0])
ligne = pyrucast.mesh.line(a, b, 2)
poutre = pyrucast.model.bernoulli(
    pyrucast.FiniteElementSpace(ligne, interpolation="HERMITE3")
)
print(poutre[0].primal_vars())  # ['u_x', 'u_y', 'r_z'] : un portique plan
encastrement = pyrucast.mesh.poi1_from_nodes([a])
modele = poutre
for v in ("u_x", "u_y", "r_z"):
    modele = modele | pyrucast.model.dirichlet(encastrement, poutre, v)
materiaux = pyrucast.element_field.material_field(
    modele, [("E", 200e3), ("A", 1e-2), ("I", 1e-4)]
)

force = pyrucast.NodeField(ligne, ["f_y"])
force.unit().set_value(b, "f_y", -10.0)
u = pyrucast.matrix.stiffness(modele, materiaux).solve(force)
print(round(u.value(b, "u_y"), 12))  # -1.333333333333 = F·L³/(3EI), exacte

Côté Rust

Bernoulli (src/models/bernoulli.rs) refuse à la construction un espace qui n’est pas HERMITE3. Son \(B\) est celui de src/models/beam.rs, partagé avec Timoshenko pour les forces internes. tests/hermite.rs compare la raideur intégrée à la forme fermée, à la précision machine ; le test ci-dessous compare la console sous charge et sous moment en bout, la traction et la torsion aux formules du cours, à 1e-12 :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Interpolation, Node};
use pyrucast::containers::field::SubField;
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::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

const E: f64 = 210_000.0;
const I: f64 = 1.0e-4;
const L: f64 = 2.0;

#[test]
fn a_cantilever_under_a_tip_load_matches_its_closed_form() -> Result<()> {
    const P: f64 = 50.0;
    let coords = Handle::new(Coords::new(1)?);
    let a = Node::create_in(coords.clone(), &[0.0])?;
    let b = Node::create_in(coords.clone(), &[L])?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    mesh.add_cell(&[a.id(), b.id()])?;
    let fes = FiniteElementSpace::new(&mesh, Interpolation::Hermite3)?;

    // Clamped at A: both the deflection and the rotation are held.
    let mut model = model::bernoulli(&fes)?;
    for var in ["w", "theta"] {
        let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(std::slice::from_ref(&a))?);
        model = model.union(&model::dirichlet(
            &imposed,
            &model,
            var,
            Default::default(),
        )?)?;
    }
    let materials = pyrucast::ops::element_field::material_field(&model, &[("E", E), ("I", I)])?;

    // A point load at the free end.
    let load_sm = Handle::new(SubMesh::poi1_from_nodes(std::slice::from_ref(&b))?);
    let mut rhs = SubNodeField::from_poi1(&load_sm, vec!["f_w".into()])?;
    rhs.set_value(b.id(), "f_w", P)?;

    let k = pyrucast::ops::matrix::stiffness(&model, &materials)?;
    let solution = solve(&k, &NodeField::from_sub(rhs))?;

    // Nodally exact: w = PL³/3EI, θ = PL²/2EI, to machine precision.
    let w = solution.value(b.id(), "w")?;
    let theta = solution.value(b.id(), "theta")?;
    let w_exact = P * L.powi(3) / (3.0 * E * I);
    let theta_exact = P * L * L / (2.0 * E * I);
    assert!(
        (w - w_exact).abs() < 1e-12 * w_exact,
        "w = {w}, exact {w_exact}"
    );
    assert!(
        (theta - theta_exact).abs() < 1e-12 * theta_exact,
        "θ = {theta}, exact {theta_exact}"
    );
    Ok(())
}

Poutre de Timoshenko

En bref

Une poutre déformable en cisaillement : la section reste plane mais pas normale à l’axe déformé. Pour une poutre trapue, la flèche due au cisaillement s’ajoute à celle de la flexion.

opérateurpyrucast.model.timoshenko(fespace)
tagmechanical
typedomaine avec comportement (label() : "Timoshenko")
élémentsSEG2, sur un espace FiniteElementSpace(maillage, interpolation="MODEL_EMBEDDED")

Comme pour Euler-Bernoulli, la configuration se lit sur la dimension des Coords : flexion pure en 1-D, portique plan en 2-D, cadre spatial en 3-D.

Théorie

La rotation \(\theta\) est un champ indépendant de la pente, et la distorsion \(\gamma = w' - \theta\) une déformation à part entière :

\[ \kappa = \theta', \qquad \gamma = w' - \theta, \qquad M = E I\,\kappa, \qquad V = G A_s\,\gamma, \qquad V' + q = 0, \qquad M' - V = 0 , \]

\(A_s\) étant l’aire de cisaillement. Deux équations du second ordre : une interpolation \(C^0\) suffit.

L’élément est la solution exacte de ces équations sur une travée sans charge répartie : il est exact aux nœuds, et un élément par barre suffit. Ses fonctions de forme, cubiques en \(w\) et quadratiques en \(\theta\), dépendent du matériau par

\[ \Phi = \frac{12\,E I}{G A_s L^2}, \]

le rapport des souplesses de flexion et de cisaillement. La raideur de flexion est

\[ K_b = \frac{E I}{L^3(1+\Phi)} \begin{bmatrix} 12 & 6L & -12 & 6L \\ 6L & (4+\Phi)L^2 & -6L & (2-\Phi)L^2 \\ -12 & -6L & 12 & -6L \\ 6L & (2-\Phi)L^2 & -6L & (4+\Phi)L^2 \end{bmatrix}, \]

où la raideur en flèche est la mise en série des deux souplesses, \(1/(L^3/12 E I + L/G A_s)\). \(\Phi = 0\) redonne Euler-Bernoulli terme à terme.

Parce que la base dépend du matériau, aucun espace ne peut la tabuler : l’espace déclare MODEL_EMBEDDED, une interpolation que la physique porte elle-même.

La masse est intégrée des mêmes fonctions de forme : \(\int \rho A\,N_w^{\mathsf T} N_w + \int \rho I\,N_\theta^{\mathsf T} N_\theta\), le second terme étant l’inertie de rotation de la section ; l’axial et la torsion gardent la masse linéaire \((\rho L/6)\,[[2, 1], [1, 2]]\). À \(\Phi = 0\), elle redonne la table classique \(\rho A L/420\cdot[156, 22L, 54, -13L, \dots]\).

Aux points de Gauss, la courbure varie linéairement et le cisaillement est constant sur une travée sans charge, comme l’imposent \(M' = V\) et \(V' = 0\). Les reconstruire demande le matériau, puisque \(\Phi\) en dépend.

Variables

1-D2-D3-D
primalesw, thetau_x, u_y, r_zu_x, u_y, u_z, r_x, r_y, r_z
dualesf_w, m_thetaf_x, f_y, m_zf_x, f_y, f_z, m_x, m_y, m_z
matériau requisE, I, G, A_sE, A, I, G, A_sE, A, I_y, I_z, J, G, A_sy, A_sz
matériau facultatifA, rhorhorho
entrée du comportementkappa, gammaeps, kappa, gammaeps, kappa_y, kappa_z, torsion, gamma_y, gamma_z
sortie du comportementM, V, phiN, M, V, phiN, M_y, M_z, T, V_y, V_z, phi_y, phi_z
étataucunaucunaucun

La sortie porte \(\Phi\) (phi) à côté des efforts : le noyau des forces internes reçoit l’état, pas le matériau, et son \(B\) dépend de \(\Phi\).

Ce qu’elle produit

raideurl’élément exact
masseexige rho (et A en 1-D)
géométriquesous l’effort N ; aucune en 1-D
tangente= raideur
forces internesle transposé du même \(B\) : \(\int B^{\mathsf T}\sigma = K\,u\) exactement

Exemple

La console de la page Euler-Bernoulli, avec une aire de cisaillement : la flèche gagne \(F\,L/(G A_s)\).

poutre = pyrucast.model.timoshenko(
    pyrucast.FiniteElementSpace(ligne, interpolation="MODEL_EMBEDDED")
)
modele = poutre
for v in ("u_x", "u_y", "r_z"):
    modele = modele | pyrucast.model.dirichlet(encastrement, poutre, v)
materiaux = pyrucast.element_field.material_field(
    modele, [("E", 200e3), ("A", 1e-2), ("I", 1e-4), ("G", 80e3), ("A_s", 5e-3)]
)
u = pyrucast.matrix.stiffness(modele, materiaux).solve(force)
print(round(u.value(b, "u_y"), 12))  # -1.383333333333 = F·L³/(3EI) + F·L/(G·A_s)

Côté Rust

Timoshenko (src/models/timoshenko.rs) partage avec Bernoulli le \(B\) de src/models/beam.rs. Le test compare une console à \(P L^3/(3 E I) + P L/(G A_s)\) :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Interpolation, Node};
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::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

#[test]
fn timoshenko_cantilever_converges_without_locking() -> Result<()> {
    const E: f64 = 1.0;
    const I: f64 = 1.0; // E·I = 1
    const G: f64 = 30.0;
    const A_S: f64 = 1.0; // G·A_s = 30 (slender ⇒ shear locking would be severe)
    const L: f64 = 1.0;
    const P: f64 = 1.0; // transverse tip load
    const N: usize = 40; // beam elements

    // ── Mesh: N SEG2 elements aligned on [0, L] (1-D configuration) ────────
    let coords = Handle::new(Coords::new(1)?);
    let h = L / N as f64;
    let nodes: Vec<Node> = (0..=N)
        .map(|i| Node::create_in(coords.clone(), &[i as f64 * h]))
        .collect::<Result<_>>()?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    for i in 0..N {
        mesh.add_cell(&[nodes[i].id(), nodes[i + 1].id()])?;
    }
    let fes = FiniteElementSpace::new(&mesh, Interpolation::ModelEmbedded)?;

    // ── Model: beam + clamping on the left (w = θ = 0) ─────────────────────
    let clamp = |target: &Model, node: &Node, var: &str| -> Result<Model> {
        let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(std::slice::from_ref(node))?);
        model::dirichlet(&imposed, target, var, Default::default())
    };
    let mut model = model::timoshenko(&fes)?;
    model = model.union(&clamp(&model, &nodes[0], "w")?)?;
    model = model.union(&clamp(&model, &nodes[0], "theta")?)?;

    // ── Matériau E, I, G, A_s ──────────────────────────────────────────────
    let materials = pyrucast::ops::element_field::material_field(
        &model,
        &[("E", E), ("I", I), ("G", G), ("A_s", A_S)],
    )?;

    // ── Loading: transverse force P at the free end (component f_w) ────────
    let mut load_sm = SubMesh::new(coords.clone(), ElementType::POI1);
    load_sm.add_cell(&[nodes[N].id()])?;
    let load_sm = Handle::new(load_sm);
    let mut rhs = SubNodeField::from_poi1(&load_sm, vec!["f_w".into()])?;
    rhs.set_value(nodes[N].id(), "f_w", P)?;
    let rhs = NodeField::from_sub(rhs);

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

    // ── Comparaison : w_tip = P·L³/(3·E·I) + P·L/(G·A_s) ───────────────────
    let w_tip = solution.value(nodes[N].id(), "w")?;
    let analytical = P * L.powi(3) / (3.0 * E * I) + P * L / (G * A_S);
    assert!(
        (w_tip - analytical).abs() < 1e-2 * analytical,
        "w_tip = {w_tip}, analytique {analytical}"
    );
    Ok(())
}

Le portique plan, où l’axial et la flexion se découplent :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Interpolation, Node};
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::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

#[test]
fn frame_inclined_cantilever_perpendicular_load() -> Result<()> {
    const E: f64 = 1.0;
    const A: f64 = 1.0;
    const I: f64 = 1.0;
    const G: f64 = 30.0;
    const A_S: f64 = 1.0;
    const L: f64 = 1.0;
    const P: f64 = 1.0;
    const N: usize = 40;

    // Beam direction (45°) and perpendicular.
    let (c, s) = (
        std::f64::consts::FRAC_1_SQRT_2,
        std::f64::consts::FRAC_1_SQRT_2,
    );
    let (px, py) = (-s, c); // unit perpendicular
    let h = L / N as f64;

    // ── Mesh: N SEG2 elements along the 45° direction ──────────────────────
    let coords = Handle::new(Coords::new(2)?);
    let nodes: Vec<Node> = (0..=N)
        .map(|i| Node::create_in(coords.clone(), &[i as f64 * h * c, i as f64 * h * s]))
        .collect::<Result<_>>()?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    for i in 0..N {
        mesh.add_cell(&[nodes[i].id(), nodes[i + 1].id()])?;
    }
    let fes = FiniteElementSpace::new(&mesh, Interpolation::ModelEmbedded)?;

    // ── Model: frame + full clamping at the base ───────────────────────────
    let clamp = |target: &Model, node: &Node, var: &str| -> Result<Model> {
        let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(std::slice::from_ref(node))?);
        model::dirichlet(&imposed, target, var, Default::default())
    };
    let mut model = model::timoshenko(&fes)?;
    model = model.union(&clamp(&model, &nodes[0], "u_x")?)?;
    model = model.union(&clamp(&model, &nodes[0], "u_y")?)?;
    model = model.union(&clamp(&model, &nodes[0], "r_z")?)?;

    let materials = pyrucast::ops::element_field::material_field(
        &model,
        &[("E", E), ("A", A), ("I", I), ("G", G), ("A_s", A_S)],
    )?;

    // ── Loading: force P perpendicular to the beam, at the free end ────────
    let mut load_sm = SubMesh::new(coords.clone(), ElementType::POI1);
    load_sm.add_cell(&[nodes[N].id()])?;
    let load_sm = Handle::new(load_sm);
    let mut rhs = SubNodeField::from_poi1(&load_sm, vec!["f_x".into(), "f_y".into()])?;
    rhs.set_value(nodes[N].id(), "f_x", P * px)?;
    rhs.set_value(nodes[N].id(), "f_y", P * py)?;
    let rhs = NodeField::from_sub(rhs);

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

    // ── Comparison: the tip's displacement = δ·(perpendicular) ─────────────
    let delta = P * L.powi(3) / (3.0 * E * I) + P * L / (G * A_S);
    let ux = solution.value(nodes[N].id(), "u_x")?;
    let uy = solution.value(nodes[N].id(), "u_y")?;
    // Projection onto the perpendicular (= δ) and onto the axis (≈ 0).
    let transverse = ux * px + uy * py;
    let axial = ux * c + uy * s;
    assert!(
        (transverse - delta).abs() < 1e-2 * delta,
        "transverse {transverse} ≠ {delta}"
    );
    assert!(axial.abs() < 1e-6, "déplacement axial {axial} ≈ 0");
    Ok(())
}

Le cadre spatial, avec sa torsion :

use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Interpolation, Node};
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::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

#[test]
fn frame3d_cantilever_bending_and_torsion() -> Result<()> {
    const E: f64 = 1.0;
    const A: f64 = 1.0;
    const IY: f64 = 1.0;
    const IZ: f64 = 2.0;
    const J: f64 = 1.0;
    const G: f64 = 0.5;
    const ASY: f64 = 10.0;
    const ASZ: f64 = 10.0;
    const L: f64 = 1.0;
    const PY: f64 = 1.0;
    const PZ: f64 = 1.0;
    const MX: f64 = 1.0;
    const N: usize = 2;

    // ── Maillage : N éléments SEG2 le long de l'axe X (config 3-D) ─────────
    let coords = Handle::new(Coords::new(3)?);
    let h = L / N as f64;
    let nodes: Vec<Node> = (0..=N)
        .map(|i| Node::create_in(coords.clone(), &[i as f64 * h, 0.0, 0.0]))
        .collect::<Result<_>>()?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    for i in 0..N {
        mesh.add_cell(&[nodes[i].id(), nodes[i + 1].id()])?;
    }
    let fes = FiniteElementSpace::new(&mesh, Interpolation::ModelEmbedded)?;

    // ── Model: 3-D frame + full clamping (6 DOFs) at the base ──────────────
    let clamp = |target: &Model, node: &Node, var: &str| -> Result<Model> {
        let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(std::slice::from_ref(node))?);
        model::dirichlet(&imposed, target, var, Default::default())
    };
    let mut model = model::timoshenko(&fes)?;
    for var in ["u_x", "u_y", "u_z", "r_x", "r_y", "r_z"] {
        model = model.union(&clamp(&model, &nodes[0], var)?)?;
    }

    let materials = pyrucast::ops::element_field::material_field(
        &model,
        &[
            ("E", E),
            ("A", A),
            ("I_y", IY),
            ("I_z", IZ),
            ("J", J),
            ("G", G),
            ("A_sy", ASY),
            ("A_sz", ASZ),
        ],
    )?;

    // ── Loading: f_y, f_z and m_x at the free end ──────────────────────────
    let mut load_sm = SubMesh::new(coords.clone(), ElementType::POI1);
    load_sm.add_cell(&[nodes[N].id()])?;
    let load_sm = Handle::new(load_sm);
    let mut rhs =
        SubNodeField::from_poi1(&load_sm, vec!["f_y".into(), "f_z".into(), "m_x".into()])?;
    rhs.set_value(nodes[N].id(), "f_y", PY)?;
    rhs.set_value(nodes[N].id(), "f_z", PZ)?;
    rhs.set_value(nodes[N].id(), "m_x", MX)?;
    let rhs = NodeField::from_sub(rhs);

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

    // ── Compared with the analytical solution (exact element ⇒ nodally exact) ─
    let tip = nodes[N].id();
    let uy = PY * L.powi(3) / (3.0 * E * IZ) + PY * L / (G * ASY);
    let uz = PZ * L.powi(3) / (3.0 * E * IY) + PZ * L / (G * ASZ);
    let rx = MX * L / (G * J);
    let tol = 1e-9;
    assert!((solution.value(tip, "u_y")? - uy).abs() < tol, "u_y");
    assert!((solution.value(tip, "u_z")? - uz).abs() < tol, "u_z");
    assert!((solution.value(tip, "r_x")? - rx).abs() < tol, "r_x");
    // Axial DOF stays put (no axial load).
    assert!(solution.value(tip, "u_x")?.abs() < tol, "u_x ≈ 0");
    Ok(())
}

Coques

En bref

Une coque est une surface qui porte à la fois des efforts de membrane (dans son plan) et des moments de flexion : une tôle, une paroi de réservoir, une dalle mince.

opérateurpyrucast.model.shell(fespace, formulation)
tagmechanical
typedomaine avec comportement (label() : "Shell")
élémentsTRI3, QUA4, dans un espace 3-D

formulation est "thick" (Reissner-Mindlin, cisaillement transverse intégré de façon réduite) ou "kirchhoff" (Kirchhoff discret, DKT et DKQ, sans cisaillement). Les deux partagent les inconnues, le repère local et la membrane ; seuls la flexion et le cisaillement changent. Chaque maille est une facette plane : une forte courbure demande un maillage plus fin.

Théorie

Six inconnues par nœud. Trois translations et trois rotations, u_x…u_z, r_x…r_z, comme le cadre spatial des poutres : une coque et un portique partagent leurs nœuds sans adaptateur. La sixième, la rotation autour de la normale (le vrillage), n’a pas de raideur physique sur une facette plane ; elle est liée à la rotation propre de la membrane, ce qui ne s’oppose pas à une rotation rigide de la facette. Cette liaison travaille : elle a un effort conjugué, M_drill.

Le repère local de chaque élément est construit depuis ses nœuds : la première arête et la normale de la facette. Les dérivées dans le plan de la facette sont le gradient tangent de la géométrie, projeté sur ce repère.

Reissner-Mindlin (thick). La fibre normale reste droite mais pas normale à la surface déformée ; dans l’épaisseur \(z \in [-h/2, h/2]\),

\[ u_x = u + z\,\theta_y, \qquad u_y = v - z\,\theta_x, \qquad u_z = w . \]

Les déformations se séparent en membrane \(\varepsilon\), courbures \(\kappa\) et cisaillement transverse \(\gamma\). Pour un matériau homogène en contraintes planes, intégré dans l’épaisseur, les lois de section sont

\[ D_m = \frac{E h}{1-\nu^2}\begin{bmatrix} 1 & \nu & 0 \\ \nu & 1 & 0 \\ 0 & 0 & \tfrac{1-\nu}2 \end{bmatrix}, \qquad D_b = \frac{h^2}{12}\,D_m, \qquad D_s = k_s\,G\,h , \]

avec \(k_s = 5/6\) par défaut (composante k_s), qui corrige un cisaillement supposé uniforme dans l’épaisseur. La raideur est \(\int_A (B_m^{\mathsf T} D_m B_m + B_b^{\mathsf T} D_b B_b + B_s^{\mathsf T} D_s B_s)\), la membrane et la flexion intégrées complètement, le cisaillement en un point. Intégré complètement, ce terme, en \(h\), écraserait la flexion, en \(h^3\), quand la coque s’amincit : l’élément refuserait de fléchir (blocage en cisaillement).

Kirchhoff discret (kirchhoff). La fibre reste normale, \(\gamma = 0\). Plutôt que des dérivées secondes de la flèche, qui demanderaient une base \(C^1\), la rotation est interpolée quadratiquement et \(\gamma = 0\) est imposé aux sommets et le long de chaque arête ; les rotations de milieu d’arête sont ensuite éliminées. Rien n’est dérivé deux fois et la base reste de Lagrange. La limite mince est exacte par construction : il n’y a pas de cisaillement à bloquer. L’effort tranchant d’une plaque mince est alors une réaction, pas une sortie de la loi.

Variables

thickkirchhoff
primalesu_x, u_y, u_z, r_x, r_y, r_zu_x, u_y, u_z, r_x, r_y, r_z
dualesf_x, f_y, f_z, m_x, m_y, m_zf_x, f_y, f_z, m_x, m_y, m_z
matériau requisE, nu, hE, nu, h
matériau facultatifrho, k_srho
entrée du comportementeps_xx, eps_yy, eps_xy, kappa_xx, kappa_yy, kappa_xy, drill, gamma_xz, gamma_yzeps_xx, eps_yy, eps_xy, kappa_xx, kappa_yy, kappa_xy, drill
sortie du comportementN_xx, N_yy, N_xy, M_xx, M_yy, M_xy, M_drill, Q_xz, Q_yzN_xx, N_yy, N_xy, M_xx, M_yy, M_xy, M_drill
étataucunaucun

L’entrée vient de pyrucast.element_field.shell_deformation, ou de strain : membrane, flexion et vrillage à chaque point de Gauss, cisaillement au seul point réduit.

Ce qu’elle produit

raideurmembrane, flexion, cisaillement (thick) et vrillage
masse—
géométrique—
tangente= raideur
forces internesle transposé du même \(B\), ramené aux axes globaux : \(\int B^{\mathsf T}\sigma = K\,u\) exactement

Exemple

Une facette carrée, ses inconnues et son matériau :

c = pyrucast.Coords(3)
n = [
    c.add_node(p)
    for p in ([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0])
]
surface = pyrucast.Mesh(c, "QUA4")
surface.unit().add_cell(n)
coque = pyrucast.model.shell(
    pyrucast.FiniteElementSpace(surface), "thick"
)  # ou "kirchhoff"
print(coque[0].primal_vars())  # ['u_x', 'u_y', 'u_z', 'r_x', 'r_y', 'r_z']
print(coque[0].material_components())  # ['E', 'nu', 'h']
materiaux = pyrucast.element_field.material_field(
    coque, [("E", 210e3), ("nu", 0.3), ("h", 0.01)]
)
K = pyrucast.matrix.stiffness(coque, materiaux)
print(K.n_rows)  # 24 : six inconnues par nœud

Côté Rust

Shell (src/models/shell.rs, src/models/shell/) porte sa formulation en attribut (ShellFormulation). Les deux formulations construisent leur \(B\) au même endroit (shell::b_into) et ne diffèrent que par ses lignes de flexion. thick intègre sur deux sous-espaces du même maillage, à quadrature complète et réduite ; la physique les construit elle-même.

Les tests retrouvent la flèche d’une plaque carrée encastrée sous charge uniforme et vérifient que le maillage converge ; ils mesurent la flèche normalisée d’une plaque mince sur deux décades d’épaisseur, qu’un élément bloqué perdrait ; ils vérifient la membrane, et qu’une rotation rigide ne coûte aucune énergie.

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::containers::node_field::{NodeField, SubNodeField};
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::models::shell::ShellFormulation;
use pyrucast::ops::model;
use pyrucast::ops::solver::solve;
use pyrucast::Result;

const E: f64 = 210_000.0;
const NU: f64 = 0.3;
/// Side of the square plate.
const A: f64 = 1.0;
/// Uniform transverse pressure.
const Q: f64 = 1.0;

#[test]
fn a_clamped_plate_matches_its_textbook_deflection() -> Result<()> {
    let h = 0.01; // thin enough to compare with plate theory, thick enough to be safe
    let w = central_deflection(12, h, ShellFormulation::Thick, ElementType::QUA4)?;
    // Timoshenko & Woinowsky-Krieger: w_max = 0.00126 · qa⁴/D.
    let d = E * h * h * h / (12.0 * (1.0 - NU * NU));
    let exact = 0.00126 * Q * A.powi(4) / d;
    assert!(
        (w - exact).abs() < 0.06 * exact,
        "central deflection {w}, textbook {exact}"
    );
    Ok(())
}

La même plaque en Kirchhoff discret, triangles et quadrangles :

#[test]
fn a_clamped_plate_matches_its_textbook_deflection_in_discrete_kirchhoff() -> Result<()> {
    let h = 0.01;
    let d = E * h * h * h / (12.0 * (1.0 - NU * NU));
    let exact = 0.00126 * Q * A.powi(4) / d;
    for element in [ElementType::QUA4, ElementType::TRI3] {
        let w = central_deflection(12, h, ShellFormulation::Kirchhoff, element)?;
        assert!(
            (w - exact).abs() < 0.03 * exact,
            "{element:?}: central deflection {w}, textbook {exact}"
        );
    }
    Ok(())
}

Grandes transformations

Une loi de petites déformations — Hooke, von Mises, Mazars — lit ε et rend σ. Quand les rotations ou les déformations deviennent grandes, elle doit lire une mesure de déformation qui ne voie que ce qui déforme vraiment la matière. pyrucast suit la voie du lagrangien total : la loi lit une mesure lagrangienne totale, rend sa contrainte duale, et tout son état reste sur la configuration de référence. Rien n’est transporté d’une configuration à l’autre.

Choisir la mesure

La mesure est un paramètre de construction de la loi :

# Hooke read on the Green–Lagrange strain: Saint-Venant–Kirchhoff. The law
# reads E_* and writes S_*, the second Piola–Kirchhoff stress.
solide = pc.model.elasticity(fes, "plane_strain", strain_measure="green_lagrange")
materiaux = pc.element_field.material_field(solide, [("E", 100.0), ("nu", 0.3)])
strain_measurela loi litelle écritdomaine
"linear" (défaut)eps_* : ε = sym ∇usigma_* : Cauchypetites déformations, petites rotations
"green_lagrange"E_* : E = ½(FᵀF − I)S_* : Piola–Kirchhoff 2petites déformations, grandes rotations
"hencky"H_* : ½ ln CT_* : sa dualegrandes déformations plastiques

Chaque composante porte le nom de ce qu’elle contient : une loi en Green–Lagrange ne lit pas des eps_* qui contiendraient E, elle lit E_*. Une loi de petites déformations relie une déformation à sa duale sans dire laquelle : la lire sur une autre mesure donne un autre matériau. Hooke sur E est Saint-Venant–Kirchhoff ; Hooke sur H est l’élasticité de Hencky ; J2 sur H est le cadre de Miehe, Apel et Lambrecht.

Le paramètre existe pour elasticity, pour les dix lois de plasticité et pour les trois lois d’endommagement. Deux combinaisons sont refusées : une mesure qui n’est pas une entrée de loi (gradient, Cauchy–Green, Euler–Almansi, J, que deformation sait pourtant produire) et Hencky en contraintes planes, où ln C demande C_zz, que seule la loi connaît.

La dilatation thermique suit la mesure : thermal_strain reçoit measure et donne l’étirement isotrope 1 + αΔT exactement dans chacune — αΔT, ½((1 + αΔT)² − 1), ln(1 + αΔT) — sous les noms que lit la loi.

Une rotation rigide ne déforme rien

# A rigid rotation of 60°: the strain, the stress and the forces are zero.
E = solide.strain(u, materiaux)  # E_xx, E_xy, E_yy
repos = solide.initial_state(materiaux)  # l'état de départ de la loi
S = solide.integrate_behavior(E, materiaux, repos)  # S_xx, S_xy, S_yy
F = pc.element_field.deformation(u, fes, "gradient")
P = pc.element_field.stress_measure(S, F, "pk1")  # P = F S
f_int = solide.internal_forces(P, u, materiaux)  # ∫ P·∇₀N dV₀

Pour une rotation R, F = R et E = ½(RᵀR − I) = 0 : pas de déformation, pas de contrainte, pas d’effort. La déformation linéarisée du même mouvement vaut ε = (cos θ − 1) I, soit −½ sur la diagonale pour 60° : une contrainte fictive considérable alors que rien ne se déforme. C’est pourquoi les structures élancées ont besoin d’une mesure finie même quand elles se déforment peu.

Les forces internes : P = F S

La contrainte que rend la loi n’est pas celle que lit l’équilibre. Les efforts intérieurs sont le travail virtuel de S dans la variation de E :

\[ \delta E = \operatorname{sym}\!\left(F^{\mathsf T} \nabla_0 \delta u\right), \qquad f_{ia} = \int_{\Omega_0} P_{ab}\, \frac{\partial N_i}{\partial X_b}\, dV_0, \qquad P = F\,S . \]

δE est la ligne du B en grandes transformations : B dépend de F, ce qui fait de internal_forces l’adjoint de strain au point u. Avec F = I, on retrouve le B habituel. En pratique, la loi rend S, on la convertit en P (la contrainte nominale, non symétrique), et internal_forces applique son Bᵀ sur la configuration de référence : c’est la même boucle qu’en petites déformations, qui lit simplement un tenseur plein (P_xx, P_xy, P_yx, P_yy).

Les mesures de contrainte : stress_measure

stress_measure(stress, gradient, target) convertit, point par point, une contrainte d’une mesure à l’autre avec le gradient de la transformation F (deformation(u, fes, "gradient")). La mesure d’entrée se reconnaît à ses noms ; la cible est nommée par la contrainte qu’on veut :

targetcontrainteà partir de Sduale de
"cauchy" (sigma)sigma_*, vraie contrainteJ⁻¹ F S Fᵀ— (eulérienne)
"kirchhoff" (tau)tau_*F S Fᵀ— (eulérienne)
"pk1" (P)P_*, non symétriqueF SF
"pk2" (S)S_*SE (Green–Lagrange)
"hencky" (T)T_*S̃_ab / θ_abH = ½ ln C (Hencky)
S = solide.integrate_behavior(solide.strain(u, materiaux), materiaux, repos)
F = pc.element_field.deformation(u, fes, "gradient")
sigma = pc.element_field.stress_measure(S, F, "cauchy")  # J⁻¹ F S Fᵀ

Hencky. La duale T de H = ½ ln C se ramène à S par S = T : ∂H/∂E. Dans la base propre de C = FᵀF = Σ c_a N_a⊗N_a (le tilde), cette dérivée agit composante par composante :

\[ \tilde S_{ab} = \theta_{ab}\,\tilde T_{ab}, \qquad \theta_{aa} = \frac{1}{c_a}, \qquad \theta_{ab} = \frac{\ln c_a - \ln c_b}{c_a - c_b} \quad (a \neq b), \]

la différence divisée du logarithme, qui tend vers 1/c quand deux valeurs propres se confondent (calculée alors par son développement, sans perte de chiffres). En traction uniaxiale (ν = 0), la loi rend T = E ln λ, d’où S = T/λ² et une contrainte nominale P = E ln λ / λ ; Saint-Venant–Kirchhoff donne P = λ E (λ² − 1)/2. Les deux formes closes sont vérifiées par tests/large_transformations.rs, avec la dualité énergie–efforts dans chaque mesure.

L’état ne change jamais de configuration : seule la contrainte se convertit, et la conversion est inversible, "pk2" rendant S depuis la Cauchy.

Deux configurations

Le corps se calcule sur la configuration de référence : sa mesure, sa loi, son état, ses forces internes. Ce qui agit sur la forme actuelle se calcule sur une configuration courante x = X + u : l’opérateur de Newton, la pression qui suit la paroi, la thermique de la pièce déformée. Le contact, lui, se réapparie sur X + u sans changer de configuration : update_contact lit u et range un jeu rapporté à la référence. Le Coords les porte toutes les deux ; on passe de l’une à l’autre en changeant d’indice, sans recopier le maillage. in_config rend la courante active le temps d’un bloc, puis rétablit celle d’avant, même si le bloc échoue :

# The current configuration x = X + u, laid once in a set of its own: the
# reference stays where it is, and in_config makes it active for one block.
courante = coords.add_config("current")
pc.coords.displace_from(u, 0, courante, ["u_x", "u_y"])
with coords.in_config(courante):
    K = pc.matrix.stiffness(solide, materiaux)  # on x = X + u
    K_sigma = pc.matrix.geometric(solide, materiaux, sigma)  # K_σ with Cauchy

displace_from pose la configuration courante en absolu, X + u, à partir de la référence : rappelée avec un autre u, elle repart de X, et rien ne s’accumule — ce qu’exigent un sous-pas repris ou une itération accélérée. La configuration active ne change pas.

Les deux membres de l’équilibre sont des forces nodales, des vecteurs en axes globaux qui ne dépendent pas de la configuration où on les calcule : ∫Ω₀ P:∇₀δv dV₀ = ∫Ω σ:∇ˢδv dv. On peut donc calculer les forces intérieures sur la référence et une charge suiveuse sur la configuration courante, puis les soustraire nœud par nœud.

La pression suiveuse

Une pression pousse sur la paroi où elle est. Le mode normal de flux — la ligne "n", une densité phi_n portée par la normale de la maille — lit la normale de la configuration active : évaluée sur la configuration courante, la charge suit la paroi.

# A pressure p = 1 pushing on the walls: a density along the normal of each
# cell, phi_n = −p, into every displacement row of the solid.
pression = pc.model.flux(pc.FiniteElementSpace(contour), solide, "n")
densite = pc.element_field.material_field(pression, [("phi_n", -1.0)])
with coords.in_config(courante):  # the wall where it is: x = X + u
    f_ext = pc.node_field.external_forces(pression, densite)

Pour un contour parcouru dans le sens trigonométrique, la normale (t_y, −t_x) d’une arête sort de la matière : une pression p qui pousse sur la paroi s’écrit phi_n = −p. Sur la plaque étirée, la paroi du bas mesure 2,4 et reçoit p · 2,4 vers le haut. La pression n’a pas de raideur de chargement : elle n’entre que dans le résidu, recalculée à chaque itéré.

La rigidité géométrique

L’opérateur de Newton se construit sur la configuration courante avec les fonctions existantes : la rigidité élastique stiffness, et la rigidité géométrique geometric avec la contrainte de Cauchy, K_e + K_σ. Deux propriétés le justifient, que vérifient les tests de tests/large_transformations.rs :

  • la rigidité géométrique spatiale, ∫Ω ∇N·σ·∇N dv sur la configuration courante, est égale à la lagrangienne, ∫Ω₀ ∇₀N·S·∇₀N dV₀ : ∇N = F⁻ᵀ∇₀N, dv = J dV₀ et σ = J⁻¹FSFᵀ se compensent au point ;
  • après une rotation rigide, la rigidité élastique évaluée sur la configuration courante est la tangente exacte des forces intérieures, pour un matériau isotrope. Hors rotation pure, elle en diffère d’un terme de l’ordre de la déformation, ce qu’un Newton modifié tolère : l’opérateur ne règle que la vitesse de convergence, la solution est fixée par le résidu.

K_σ ne change donc que la convergence, sauf là où elle donne la seule raideur (un câble ou une membrane tendus, transversalement) et à l’approche du flambement, où K + K_σ devient singulière.

Les barres et les câbles

Une barre porte un effort le long de son axe, pas un tenseur. Construite avec strain_measure="green_lagrange", sa loi lit E_* et en prend la composante le long de la direction de référence t₀ de la maille : sur un segment, le gradient est tangentiel, F t₀ = (x_B − x_A)/L est la corde courante rapportée à la longueur au repos, et

\[ t_0 \cdot E\, t_0 = \tfrac12 (\lambda^2 - 1) = \frac{l^2 - L^2}{2L^2}, \]

exactement, quelle que soit la rotation. La loi écrit l’effort de Piola–Kirchhoff 2 axial S_n = E A (t₀·E t₀). stress_measure reconnaît une barre à ce nom et en fait :

targetcomposantesvaleur
"pk1"P_x, P_y(, P_z)S_n F t₀, le long de la corde courante
"cauchy"nλ S_n, l’effort vrai
"pk2"S_ninchangé

Les forces intérieures d’une barre en Green–Lagrange lisent P sur la référence : −P au premier nœud, +P au second. La rigidité géométrique lit l’effort vrai n sur la configuration courante et vaut n/l · I : c’est le terme de contrainte de la tangente exacte, S_n/L₀ = n/l, dans toutes les directions, l’axiale comprise. La rigidité élastique sur la configuration courante, E A/l c⊗c, diffère de la tangente exacte E A λ²/L₀ d’un facteur λ³, comme celle du milieu continu en traction uniaxiale : seule la vitesse de convergence en dépend.

# A cable: the bar's law reads Green–Lagrange along its reference direction,
# (l² − L²)/2L², and writes the second Piola–Kirchhoff axial force S_n.
cable = pc.model.truss(fes_barre, strain_measure="green_lagrange")
section = pc.element_field.material_field(cable, [("E", 100.0), ("A", 2.0)])
repos = cable.initial_state(section)  # l'état de départ de la loi
S_n = cable.integrate_behavior(cable.strain(deplacement, section), section, repos)
F = pc.element_field.deformation(deplacement, fes_barre, "gradient")
P = pc.element_field.stress_measure(S_n, F, "pk1")  # S_n F t₀, along the chord
n = pc.element_field.stress_measure(S_n, F, "cauchy")  # λ S_n, the true force
f_int = cable.internal_forces(P, deplacement, section)  # −P at a, +P at b

Hencky est refusé pour une barre : t₀·½ ln C·t₀ ne vaut ln λ que tant que t₀ est une direction propre de C, ce qu’une barre qui tourne n’est plus. Une barre tendue, un câble, n’a de raideur transversale que par K_σ : sans elle, l’opérateur du pas à pas est singulier.

La conduction

La conductivité donnée au matériau est celle de la matière telle qu’elle est : déformer la pièce ne change pas ses valeurs. La thermique se calcule donc sur la pièce déformée, avec la conductivité donnée — conduction, échanges (convection, rayonnement, flux imposés) — et la loi écrit le flux sur la pièce déformée, et non un flux rapporté à la référence. Une barre étirée à λ est plus longue : à écart de température donné, la chaleur qui la traverse est k ΔT A / (λ L₀).

Une conduction orientée porte ses axes dans le matériau (V1*, V2*), donnés sur la référence. Sur la pièce déformée, ils tournent avec elle, de la rotation R de la décomposition polaire F = R U : k = R k₀ Rᵀ, et la répartition de température d’une pièce tournée est celle de la pièce au repos. L’étirement U ne touche ni les axes ni les valeurs. rotate_frame tourne les axes d’un champ de matériau, avec F calculé sur la référence :

# A conduction whose strong axis runs along x at rest. On the plate stretched
# by 1.2 then turned by 60°, the axes turn with the part — by the rotation R of
# F = R U, not by the stretch — and the conductivities stay the material's.
conduction = pc.model.heat_conduction(fes, symmetry="orthotropic")
axes = pc.element_field.material_field(
    conduction,
    [("k_1", 10.0), ("k_2", 1.0), ("k_3", 1.0), ("V1X", 1.0), ("V1Y", 0.0)],
)
F = pc.element_field.deformation(tourne, fes, "gradient")  # on the reference
tournes = pc.element_field.rotate_frame(axes, F)  # V1 = (cos 60°, sin 60°)

La capacité seule reste sur la référence : la masse se conserve, ρ c dv = ρ₀ c dV₀, si bien que la capacité de référence est exacte. L’opérateur réunit la conduction et les échanges, assemblés sur la configuration courante, et la capacité, assemblée sur la référence : une matrice garde les valeurs de la configuration où elle a été assemblée, et la somme les additionne telles quelles.

Dans le pas à pas

stepping.step_by_step lit la mesure sur les lois et suit cette voie de lui-même : forces intérieures sur P et capacité thermique en référence, pression suiveuse, thermique (axes matériau tournés) et opérateur sur la configuration courante. Ses résultats gardent la contrainte lagrangienne de la loi ; la Cauchy s’en déduit comme plus haut, avec le déplacement enregistré. Voir Calcul pas à pas. Les tests tests/python/test_stepping_large.py y vérifient :

  • une bande tournée de 90° par un bord tourne sans contrainte ;
  • un quart de carré sous pression suiveuse garde une contrainte de Cauchy −p I exacte ;
  • un cube parfaitement plastique en Hencky, étiré à λ = 1,5, tire A₀ σ_y / λ ;
  • une bande étirée à λ conduit comme la bande plus longue qu’elle est, flux k ΔT / (λ L₀) ;
  • une bande à conduction orientée, tournée, garde en chaque nœud la température du repos ;
  • une bande refroidie par convection sur une face, étirée à λ en long et μ en travers : la face est à T₀ G_c / (G_c + G_h), avec G_c = k μ H₀ / (λ L₀) et G_h = h μ H₀, par les deux schémas thermiques non linéaire et linéaire ;
  • la même bande, isolée sauf sur sa face refroidie et de conductivité assez grande pour rester uniforme : chaque pas implicite donne T ← T C / (C + G_h Δt), avec la capacité de référence C = ρ c V₀.
  • un treillis de von Mises, poussé au sommet en pilotage automatique, suit P(w) = E A ((h² − (h − w)²) (h − w)) / L³ à travers ses deux points limites, la taille des pas étant la flèche du sommet (crochet automatic_measure) ;
  • une corde tendue, chargée en son milieu, prend la flèche de l’équilibre exact 2 S_n w / L = P ; sans K_σ, l’opérateur est singulier ;
  • un nœud tiré par une barre le long d’un sol rigide brisé, un plat puis une rampe, quitte le plat pour la rampe : il s’arrête où l’équilibre le long de la rampe le place, la réaction normale à la rampe — et non où un contact figé sur le plat l’aurait laissé ;
  • un carré pressé à travers sa propre base se retourne (J < 0) : le pas échoue en le disant, là où une loi de Green–Lagrange aurait calculé sans broncher ; pressé à mi-hauteur, il converge, et J = 1/2.

Contraintes

Une contrainte impose une relation aux inconnues du problème (une valeur imposée, une liaison) sans être une physique au sens d’une loi de comportement : elle n’a ni matériau, ni intégrande volumique. pyrucast les traite comme des SubModel ordinaires, contribuant leurs blocs à la même matrice globale que les physiques — pas de système point-selle séparé à orchestrer côté utilisateur.

Ce qu’elles ont en commun

tagconstraint, et le tag de la physique dont elles figent les inconnues ; constraint seul pour une relation qui lie deux physiques
typecontrainte : ni sous-espace (fespace() rend None), ni matériau, ni comportement
primalelambda, le multiplicateur, un par relation
dualeimposed, la ligne de la relation, où s’écrit son second membre
raideurles blocs C et Cᵀ ; aucune masse ni raideur géométrique
tangente= raideur
forces internesCᵀλ sur les duales de la cible, C·u sur les lignes imposed

Multiplicateurs de Lagrange

Les contraintes sont imposées par multiplicateurs de Lagrange. Une relation C·u = u_d introduit une inconnue supplémentaire λ (le multiplicateur) et deux blocs rectangulaires dans la matrice :

\[ \begin{bmatrix} K & C^\top \\ C & 0 \end{bmatrix} \begin{bmatrix} u \\ \lambda \end{bmatrix} = \begin{bmatrix} f \\ u_d \end{bmatrix} \]

  • le bloc C porte la relation de contrainte (lignes = duale propre, colonnes = primale contrainte de la cible) ;
  • le bloc Cᵀ réinjecte l’effort du multiplicateur dans l’équation de la physique cible ;
  • à la solution, le multiplicateur λ est l’effort de la pièce sur la contrainte : la première ligne donne Cᵀλ = f − K·u.

La réaction est la force que la contrainte exerce sur la pièce : le champ −Cᵀλ, aux nœuds contraints. Elle se lit par −internal_forces(modele.filter("constraint"), …), que solve ait gardé les multiplicateurs ou éliminé les relations : internal_forces d’une contrainte rend Cᵀλ. Le pas à pas la range dans ses résultats, sous "reactions".

Chacun des blocs C et Cᵀ est, pris isolément, non symétrique ; seule leur union C ∪ Cᵀ l’est — c’est une propriété globale du système point-selle, pas de chaque bloc.

C’est exactement ce que la déclaration de symétrie rend exprimable : chacun des deux déclare porter une moitié (Symmetry::Half), avec une identité de paire commune que seul constraint_block_pair mint — celui qui écrit le même coefficient des deux côtés. L’agrégat conclut à la symétrie quand il tient les deux moitiés, et la retire si un découpage en sépare une.

Les nœuds-multiplicateurs sont des nœuds comme les autres, créés par la contrainte : un par relation, dans le même Coords, à la position d’un nœud de la relation (le nœud imposé pour Dirichlet, celui du premier terme pour une MPC, le nœud immergé pour le baignage, le nœud esclave pour le contact). multiplier_mesh() les donne.

Imposition par élimination (condensation)

Voie alternative aux multiplicateurs, sélectionnée au moment de la résolution par solve(…, relations="eliminate"). Plutôt que d’agrandir le système, on élimine chaque relation : un terme esclave s est exprimé par les maîtres,

\[ u_s = \frac{1}{a_s}\Big(g - \sum_{k \neq s} a_k\, u_k\Big), \]

d’où une transformation globale u = T·û + u₀ (û = DOFs retenus). Le système se réduit à K̂ û = f̂ avec K̂ = Tᵀ K T, f̂ = Tᵀ(f − K·u₀), résolu par la même factorisation, LU ou Cholesky, mais sur une matrice plus petite et définie, sans inconnue multiplicateur. La solution est prolongée u = T·û + u₀, et le multiplicateur de chaque relation est récupéré en post-traitement, aₛ·λ = f − K·u à la ligne de son esclave, puis rendu à son nœud multiplicateur : la solution est celle de la voie Lagrange, mêmes inconnues sur les mêmes supports.

Les deux voies lisent la même description méthode-neutre des contraintes (Constraint::relations()) et acceptent le même chargement (le second membre g au nœud-multiplicateur, ci-dessous) : passer de l’une à l’autre ne change que l’appel de résolution.

Périmètre : non chaîné, esclaves disjoints — chaque relation élimine un esclave distinct, jamais réutilisé comme maître ni esclave dans une autre relation (couvre la périodicité). Un système chaîné est refusé avec une erreur explicite ; il reste résoluble par la voie Lagrange (solve).

Relations unilatérales (inégalités)

Toute relation d’égalité peut devenir unilatérale : Σₖ aₖ·uₖ ≥ g (ou ≤ g) au lieu de = g. C’est le paramètre optionnel sense des contraintes ("=" par défaut, ">=", "<=") — une butée u ≥ a est un Dirichlet unilatéral, une liaison à jeu est une MPC unilatérale :

barre = pyrucast.model.truss(fes)
butee = pyrucast.model.dirichlet(imposed, barre, "u_x", sense=">=")

Une relation unilatérale obéit aux conditions de complémentarité (KKT) : ou bien elle est active (l’égalité tient, et sa réaction pousse), ou bien elle est inactive (le jeu C·u − g est du côté admissible et λ = 0). Avec la convention des blocs assemblés (K·u + Cᵀ·λ = f), le signe admissible du multiplicateur est λ ≤ 0 pour une relation ≥ active, λ ≥ 0 pour une ≤ active.

Comme l’ensemble actif n’est pas connu d’avance, la résolution est itérative — la méthode du statut (active-set), que solve lance dès que la matrice porte une inégalité :

  1. statut initial : toutes les inégalités actives (ou le statut convergé précédent quand le cache est chaud — warm start) ;
  2. résolution du système point-selle avec, pour chaque relation inactive, sa ligne de contrainte remplacée par λ = 0 (la matrice garde sa taille, seules les valeurs changent) ;
  3. mise à jour du statut : une relation active dont le λ tire (signe inadmissible) est relâchée, une inactive dont le jeu pénètre est activée ;
  4. statut stable ⇒ convergé ; sinon on refactorise et on répète (boucle finie de la méthode du statut classique, bornée par max_iter).
# La matrice sait quelles relations sont des inégalités : `solve` lance l'ensemble actif.
solution = pyrucast.solver.solve(k, rhs)  # active_set, max_iter, tol

Les relations inactives sortent avec λ = 0 exact ; la solution a la même forme que la voie Lagrange (primal + multiplicateurs). Les coefficients assemblés sont inchangés : le sense est posé sur le bloc C de la contrainte, et c’est là que solve le lit — aucun modèle n’est passé à la résolution, aucun ne peut contredire la matrice. Une matrice sans inégalité est résolue par la factorisation ordinaire.

Le second membre g (la borne) suit le mécanisme habituel : au slot imposed du nœud-multiplicateur, donc constraint_rhs fonctionne tel quel.

Second membre : le helper constraint_rhs

Le second membre u_d / g n’est pas stocké dans la contrainte : l’utilisateur l’écrit dans le NodeField de chargement, à la composante imposed (la duale propre de toute contrainte), au nœud-multiplicateur de la relation. Retrouver ce nœud à la main est fastidieux ; le helper le fait :

rhs = dirichlet.constraint_rhs([(noeud_contraint, u_d)])
rhs = mpc.constraint_rhs([(noeud_terme, g)])
  • on désigne chaque relation par un nœud : le nœud contraint pour Dirichlet (un seul par relation), n’importe quel nœud-terme pour une MPC ;
  • le helper résout ce nœud vers le nœud-multiplicateur de sa relation (via relations()) et y écrit la valeur, à la composante duale de la contrainte ;
  • il renvoie un NodeField neuf sur tous les nœuds-multiplicateurs (les relations non citées valent 0), à fusionner dans le chargement global avec | : load | dirichlet.constraint_rhs(…) | mpc.constraint_rhs(…).

Le modèle passé doit porter exactement une contrainte (l’objet dirichlet ou mpc). Une erreur est levée si un nœud n’appartient à aucune relation, ou s’il en désigne plusieurs (ambigu).

Désigner par index de relation

Quand un même nœud participe à plusieurs relations (le keying par nœud est alors ambigu), on désigne la relation par son index (0-based, dans l’ordre de relations()) :

rhs = mpc.constraint_rhs_by_index([(index_relation, g)])

Le champ renvoyé et la fusion par | sont identiques ; une erreur est levée si un index dépasse le nombre de relations.

Contraintes disponibles

  • Dirichlet — impose la valeur d’une variable primale (T = u_d, u_x = 0, …) sur un ensemble de nœuds. C’est la relation à un seul terme 1·u = u_d.
  • Multi-points (MPC) — impose une relation linéaire à N termes Σₖ aₖ·u(nœudₖ, varₖ) = g entre plusieurs DOFs (égalité, périodicité, liaison affine…). Généralise Dirichlet.
  • Baignage (embedded) — lie chaque nœud d’un maillage immergé à l’interpolation d’un maillage hôte au même point (u_c(p) = Σᵢ Nᵢ(ξ_p)·u_c(hôteᵢ)) : une barre baignée dans un volume. Les poids Nᵢ sont calculés par localisation de point à la construction ; c’est une MPC dont les coefficients varient par nœud.
  • Contact (nœud-surface) — empêche les nœuds d’un maillage esclave de pénétrer une surface maître : une relation unilatérale (≥) par nœud esclave, à coefficients n·Nᵢ calculés par projection à la construction (petits glissements, sans frottement). Résolu par solve, qui y trouve des inégalités.

Une nouvelle contrainte suit le même patron : une struct implémentant SubModelKind, des blocs C/Cᵀ littéraux. Voir Ajouter une physique.

Dirichlet

En bref

Impose la valeur d’une inconnue sur des nœuds : T = 20 sur un bord, u_x = 0 sur un appui. Avec sense, impose une borne : u_x ≥ 0 est une butée.

opérateurpyrucast.model.dirichlet(imposed_mesh, target, variable, sense="=")
tagconstraint et celui de la cible
typecontrainte (label() : "Dirichlet")
supportun maillage POI1 : les nœuds imposés, une relation par maille

target est le modèle contraint, variable l’une de ses inconnues ; une variable que la cible ne déclare pas est refusée à la construction. Une seule variable par contrainte : pour encastrer, on compose dirichlet(noeuds, m, "u_x") | dirichlet(noeuds, m, "u_y").

Théorie

Chaque nœud \(n\) du support porte une relation à un seul terme,

\[ u(n) = u_d \qquad (\text{ou } \ge,\ \le\ u_d), \]

imposée par un multiplicateur \(\lambda\) (voir Contraintes). Elle apporte deux blocs à la raideur, chacun de coefficient 1 :

  • le bloc \(C\) : ligne (multiplicateur, imposed), colonne (n, variable) ;
  • le bloc \(C^{\mathsf T}\) : ligne (n, duale de la variable), colonne (multiplicateur, lambda).

À la solution, \(\lambda\) est l’effort de la pièce sur le blocage : le flux ou la force qui tient la valeur imposée, vu de la pièce. La réaction, ce que le blocage exerce sur la pièce, est \(-\lambda\) : une poutre encastrée qui porte un poids \(P\) vers le bas a des multiplicateurs u_z de somme \(-P\), et une réaction \(P\) vers le haut (voir Contraintes).

Chaque relation a son nœud multiplicateur, créé par la contrainte à la position du nœud imposé, dans le même Coords ; multiplier_mesh() les donne. La valeur imposée \(u_d\) n’est pas stockée dans la contrainte : elle s’écrit dans le chargement, à la composante imposed du nœud multiplicateur, ce que fait constraint_rhs([(noeud_impose, u_d)]). Une relation absente du chargement impose 0.

Variables

primalelambda, le multiplicateur
dualeimposed, la ligne de la relation
ce qu’elle contraintvariable, une inconnue de la cible, dont la duale reçoit la réaction
matériau, comportementaucun

Ce qu’elle produit

raideurles blocs \(C\) et \(C^{\mathsf T}\), une paire par zone du support
masse, géométrique—
tangente= raideur
forces internes\(C^{\mathsf T}\lambda\) sur la duale de la cible, \(C\,u\) sur les lignes imposed
forces externesaucune ; la valeur imposée est un chargement

Exemple

Une barre \([0, 1]\) en conduction, \(T(0) = 0\) et \(T(1) = 1\) : le profil est \(T = x\), et les multiplicateurs sont les flux \(\pm1\) aux deux bouts.

import pyrucast

# 1) Maillage et espace éléments finis
c = pyrucast.Coords(dim=1)
nodes = [c.add_node([i / 4.0]) for i in range(5)]
mesh = pyrucast.Mesh(c, "SEG2")
for i in range(4):
    mesh.unit().add_cell([nodes[i], nodes[i + 1]])
fes = pyrucast.FiniteElementSpace(mesh)

# 2) Les deux appuis ; chacun crée son nœud multiplicateur près du nœud imposé.
imposed_left = pyrucast.mesh.poi1_from_nodes([nodes[0]])
imposed_right = pyrucast.mesh.poi1_from_nodes([nodes[-1]])
conduction = pyrucast.model.heat_conduction(fes)
left = pyrucast.model.dirichlet(imposed_left, conduction, "T")
right = pyrucast.model.dirichlet(imposed_right, conduction, "T")
mult_left = left.multiplier_mesh().node(0, 0, 0)
mult_right = right.multiplier_mesh().node(0, 0, 0)

# 3) Le modèle : la conduction et les deux appuis.
model = conduction | left | right
materials = pyrucast.element_field.material_field(model, [("k", 1.0)])

# 4) Chargement : `constraint_rhs` désigne chaque relation par son nœud imposé
#    et écrit la valeur à la composante `imposed` de son nœud multiplicateur.
#    Les deux se réunissent par `|`.
rhs = left.constraint_rhs([(nodes[0], 0.0)]) | right.constraint_rhs([(nodes[-1], 1.0)])

# 5) Assemblage + résolution.
K = pyrucast.matrix.stiffness(model, materials)
solution = pyrucast.solver.solve(K, rhs)
assert abs(solution.value(nodes[2], "T") - 0.5) < 1e-10  # T au milieu
assert abs(solution.value(mult_left, "lambda") - 1.0) < 1e-10  # le flux à gauche

Côté Rust

Dirichlet (src/models/dirichlet.rs) implémente Constraint : ses relations (relations()) et ses nœuds multiplicateurs. Ses blocs sortent de constraint_block_pair, partagé avec les autres contraintes, qui donne aux deux blocs une même identité de paire (Symmetry::Half) : la matrice n’est symétrique que si elle les tient tous les deux. Ils entrent dans la raideur par contributions, comme blocs écrits.

Multi-points (MPC)

En bref

Impose une relation linéaire entre plusieurs inconnues : deux nœuds à la même température, une périodicité, une liaison affine. Avec sense, une borne : une liaison à jeu.

opérateurpyrucast.model.mpc(terms, target, sense="=")
tagconstraint et celui de la cible ; constraint seul si la relation lie deux physiques
typecontrainte (label() : "Mpc")
supportun maillage POI1 par terme

terms est une liste de termes (maillage, variable, coefficient). Les maillages des termes sont appariés maille par maille : la relation \(r\) relie la maille \(r\) de chacun. Une périodicité entre deux surfaces de \(N\) nœuds est donc \(N\) relations d’un coup. L’ordre des mailles est à la charge de l’appelant ; la contrainte vérifie que tout est POI1, sur un même Coords, avec le même nombre de mailles par zone. target donne à chaque variable sa duale (Model.dual_of) ; une relation entre deux physiques se pose sur leur union.

Théorie

Chaque relation s’écrit

\[ \sum_k a_k\,u(n_k, v_k) = g \qquad (\text{ou } \ge,\ \le\ g). \]

C’est Dirichlet à plusieurs termes et coefficients quelconques. Chaque terme apporte une paire de blocs de coefficient \(a_k\) : \(C\), ligne (multiplicateur, imposed), colonne (n_k, v_k) ; et \(C^{\mathsf T}\), ligne (n_k, duale de v_k), colonne (multiplicateur, lambda). Tous les termes d’une relation partagent son nœud multiplicateur et sa ligne imposed, ce qui les additionne en une équation. La réaction, la force que la relation exerce sur la pièce, est \(-a_k\,\lambda\) au nœud de chaque terme.

Le nœud multiplicateur de chaque relation est créé à la position du nœud de son premier terme. Le second membre \(g\) s’écrit au chargement, à la composante imposed du nœud multiplicateur, par constraint_rhs([(un_noeud_de_la_relation, g)]), ou par constraint_rhs_by_index([(r, g)]) quand un nœud appartient à plusieurs relations. Une relation absente du chargement impose 0.

Les coefficients sont un par terme, les mêmes pour toutes ses mailles. Des coefficients qui varient d’un nœud à l’autre sont le cas du baignage.

Variables

primalelambda
dualeimposed
ce qu’elle contraintles variables des termes, inconnues de la cible
matériau, comportementaucun

Ce qu’elle produit

raideurune paire \(C\), \(C^{\mathsf T}\) par terme et par zone
masse, géométrique—
tangente= raideur
forces internes\(C^{\mathsf T}\lambda\) sur les duales, \(C\,u\) sur les lignes imposed
forces externesaucune

Exemple

Sur une barre en conduction, \(T(0) = 0\) par Dirichlet et \(T(1) - T(0) = 1\) par une MPC : d’où \(T = x\).

import pyrucast

c = pyrucast.Coords(dim=1)
nodes = [c.add_node([i / 4.0]) for i in range(5)]
mesh = pyrucast.Mesh(c, "SEG2")
for i in range(4):
    mesh.unit().add_cell([nodes[i], nodes[i + 1]])
fes = pyrucast.FiniteElementSpace(mesh)

base = pyrucast.model.heat_conduction(fes)
dual = base.dual_of("T")  # "q"

# Dirichlet T(0) = 0.
imposed0 = pyrucast.mesh.poi1_from_nodes([nodes[0]])
dirichlet = pyrucast.model.dirichlet(imposed0, base, "T")

# MPC 1·T(dernier) − 1·T(0) = 1.
mesh_last = pyrucast.mesh.poi1_from_nodes([nodes[-1]])
mesh_first = pyrucast.mesh.poi1_from_nodes([nodes[0]])
mpc = pyrucast.model.mpc(
    [(mesh_last, "T", 1.0), (mesh_first, "T", -1.0)],
    base,
)

model = base | dirichlet | mpc
materials = pyrucast.element_field.material_field(model, [("k", 1.0)])

# Chargement : valeur imposée de Dirichlet + second membre g de la MPC. Le
# `constraint_rhs` désigne chaque relation par un nœud (le nœud imposé pour
# Dirichlet, un nœud d'un terme pour la MPC) et trouve son nœud multiplicateur ;
# la composante est `imposed` pour les deux. Les deux se réunissent par `|`.
rhs = dirichlet.constraint_rhs([(nodes[0], 0.0)]) | mpc.constraint_rhs(
    [(nodes[-1], 1.0)]
)

solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), rhs)
assert abs(solution.value(nodes[2], "T") - 0.5) < 1e-10

examples/mpc_periodicite.py pose une périodicité.

Côté Rust

Mpc (src/models/mpc.rs) implémente Constraint. Ses blocs sortent du même constraint_block_pair que Dirichlet, avec le coefficient \(a_k\) au lieu de 1.

Baignage (embedded)

En bref

Lie chaque nœud d’un maillage immergé à l’interpolation d’un maillage hôte au même point : une barre d’armature baignée dans un béton, qui suit ses déplacements sans partager ses nœuds.

opérateurpyrucast.model.embedded(immersed, host, target, variables, tol=1e-6)
tagconstraint et celui de la cible
typecontrainte (label() : "Embedded")
supportles nœuds du maillage immergé, et les mailles de l’hôte

variables sont les inconnues liées (["u_x", "u_y", "u_z"]), toutes inconnues de la cible. Les deux maillages partagent un même Coords. L’hôte peut être de tout type de maille à domaine de référence (segments, triangles, quadrangles, tétraèdres, prismes, hexaèdres, linéaires et quadratiques) ; le maillage immergé n’est lu que par ses nœuds.

Théorie

Pour un nœud immergé \(p\) logé dans la maille hôte de fonctions de forme \(N_i\), et pour chaque variable \(c\) :

\[ u_c(p) - \sum_i N_i(\xi_p)\,u_c(\text{hôte}_i) = g_c , \]

\(g_c = 0\) étant la liaison rigide. C’est une MPC dont les coefficients, \(+1\) sur le nœud immergé et \(-N_i(\xi_p)\) sur chaque nœud hôte, varient d’un nœud à l’autre.

Les poids sont calculés une fois, à la construction, en localisant chaque nœud immergé dans l’hôte : un Newton sur \(x - \sum_i N_i(\xi)\,X_i\), avec un test d’appartenance au domaine de référence de la maille, à tol près. Les mailles hôtes sont parcourues une à une, filtrées par leur boîte englobante. Un nœud immergé qui ne tombe dans aucune maille hôte est refusé.

Chaque relation (un nœud immergé et une variable) a son nœud multiplicateur, placé au nœud immergé ; multiplier_mesh() porte une zone par variable. Le second membre \(g_c\) s’écrit au chargement par constraint_rhs ; absent, il vaut 0.

Variables

primalelambda, la force de liaison
dualeimposed
ce qu’elle contraintles variables, au nœud immergé et aux nœuds hôtes
matériau, comportementaucun

Ce qu’elle produit

raideurune paire \(C\), \(C^{\mathsf T}\) par variable
masse, géométrique—
tangente= raideur
forces internes\(C^{\mathsf T}\lambda\) sur les duales, \(C\,u\) sur les lignes imposed
forces externesaucune

Exemple

Un cube HEX8 en conduction, ses coins tenus au champ linéaire \(T = 1 + 2x + 3y + 4z\), que l’interpolation trilinéaire reproduit exactement ; un nœud immergé au cœur prend la valeur de l’hôte en ce point.

import pyrucast

corners = [
    [0, 0, 0],
    [1, 0, 0],
    [1, 1, 0],
    [0, 1, 0],
    [0, 0, 1],
    [1, 0, 1],
    [1, 1, 1],
    [0, 1, 1],
]
field = lambda c: 1.0 + 2.0 * c[0] + 3.0 * c[1] + 4.0 * c[2]

c = pyrucast.Coords(dim=3)
corner_nodes = [c.add_node(x) for x in corners]

host = pyrucast.Mesh(c, "HEX8")
host.unit().add_cell(corner_nodes)
fes = pyrucast.FiniteElementSpace(host)
base = pyrucast.model.heat_conduction(fes)

# Les coins tenus au champ linéaire (Dirichlet).
corner_mesh = pyrucast.mesh.poi1_from_nodes(corner_nodes)
dirichlet = pyrucast.model.dirichlet(corner_mesh, base, "T")

# Le nœud immergé, lié à l'hôte.
p = c.add_node([0.3, 0.6, 0.2])
bar = pyrucast.mesh.poi1_from_nodes([p])
embedded = pyrucast.model.embedded(bar, host, base, ["T"])
emb_mult = embedded.multiplier_mesh().node(0, 0, 0)

model = base | dirichlet | embedded
materials = pyrucast.element_field.material_field(model, [("k", 1.0)])

# Chargement : la valeur du champ à chaque coin, g = 0 (la liaison) au nœud immergé.
rhs = dirichlet.constraint_rhs([(n, field(x)) for n, x in zip(corner_nodes, corners)])
rhs = rhs | embedded.constraint_rhs([(p, 0.0)])

solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), rhs)
assert abs(solution.value(p, "T") - field([0.3, 0.6, 0.2])) < 1e-9  # 4.2

examples/barre_baignee_elastique.py baigne une barre dans un volume élastique, sur les trois déplacements.

Côté Rust

Embedded (src/models/embedded.rs) implémente Constraint. La localisation passe par ops::geom::locate_points, l’inversion de la transformation de référence.

Contact (nœud-surface)

En bref

Empêche les nœuds d’un maillage esclave de pénétrer une surface maître, sans frottement : deux blocs posés l’un sur l’autre, une pièce contre un appui.

opérateurpyrucast.model.contact(slave, master, target, variables)
tagconstraint et celui de la cible
typecontrainte (label() : "Contact")
supportles nœuds esclaves, et les facettes maîtres : SEG2, SEG3 en 2-D ; TRI3, QUA4, TRI6, QUA8, QUA9 en 3-D

variables sont les déplacements de la cible, un par dimension de l’espace, dans l’ordre (["u_x", "u_y"]). Les deux maillages partagent un même Coords. La surface maître doit être orientée vers le corps esclave : en 2-D, la normale d’un segment est sa tangente tournée de −90°, \(n = (t_y, -t_x)\) ; en 3-D, elle suit la règle de la main droite sur l’ordre des nœuds.

Théorie

Chaque nœud esclave \(s\) est apparié à son point le plus proche sur la surface maître : une facette, des poids \(N_i(\xi)\), une normale \(n\) et un jeu signé \(g_0\), positif quand c’est décollé. La non-pénétration linéarisée est une relation unilatérale par nœud esclave :

\[ g_0 + n\cdot u(s) - \sum_i N_i(\xi)\,n\cdot u(\text{maître}_i) \ \ge\ 0 . \]

Une seule relation scalaire par nœud esclave : la normale couple les composantes, avec les coefficients \(+n_c\) sur l’esclave et \(-N_i\,n_c\) sur chaque nœud maître. solve y trouve des inégalités et lance l’ensemble actif (voir Contraintes) : le multiplicateur vaut \(\lambda \le 0\) tant que la paire touche, et exactement 0 quand elle est décollée : la réaction de contact, \(-C^{\mathsf T}\lambda\), ne fait que pousser.

L’appariement est calculé à la construction, par projection au point le plus proche (ops::geom::project_points), \(\xi\) ramené au domaine de référence : un nœud face à un bord se projette sur le bord. Il est ensuite figé : petits déplacements, petits glissements.

Une surface maître courbe reste courbe. La projection se fait sur la facette elle-même, avec ses propres fonctions de forme : une facette TRI6, QUA8 ou QUA9 est courbe, et le point apparié comme sa normale sont pris sur cette surface courbe. Pour une pièce maillée en éléments quadratiques, on donne donc comme surface maître les faces du maillage telles quelles : il ne faut pas les découper en triangles plats TRI3.

Un triangle plat tendu entre deux nœuds d’un bord courbe en suit la corde, et la corde s’écarte du vrai bord. Dans un alésage, elle passe à l’intérieur du trou : un nœud esclave posé sur le vrai bord se retrouve derrière la facette, compté comme déjà enfoncé, et le solveur le repousse. L’axe entre alors en force dans un trou devenu trop petit, et les deux pièces sont chargées avant que le chargement commence. Sur une surface bombée vers l’esclave, c’est l’inverse : un jeu qui n’existe pas, et un contact qui se ferme trop tard.

L’écart est la flèche de la corde, \(r\,(1 - \cos(\Delta\theta/2))\) pour un bord de rayon \(r\) dont la facette couvre l’angle \(\Delta\theta\). Raffiner le maillage le réduit vite, puisqu’il se divise par quatre quand la taille des mailles est divisée par deux ; mais un contact se joue sur de petits déplacements, et cet écart s’y ajoute tel quel. Une face quadratique passe aussi par les nœuds milieux et se courbe entre eux : elle reste bien plus près du vrai bord. Dans un maillage linéaire, les faces TRI3 et QUA4 sont plates de toute façon : la flèche est là, et seul le raffinement du bord courbe la réduit.

Le jeu est un chargement que porte le contact. Le second membre de chaque relation est \(-g_0\), une donnée géométrique : external_forces le rend, à la composante imposed de chaque nœud multiplicateur, avec les autres chargements du modèle.

Grands glissements. update_contact(modele, deplacement) (ou modele.update_contact(deplacement)) rend le modèle avec ses contacts appariés de nouveau sur les positions \(X + u\), \(X\) étant la configuration active ; les autres sous-modèles sont partagés tels quels. Les nœuds multiplicateurs ne changent pas ; les coefficients et le second membre, si. Le jeu rangé est celui du nouvel appariement mesuré sur \(X\),

\[ \tilde g = n\cdot\Big(X_s - \textstyle\sum_i N_i\,X_{m_i}\Big), \]

si bien que \(\tilde g + C\,u\) est le jeu réel en \(X + u_k\), linéarisé là. Pour une projection au point le plus proche, la variation du jeu se réduit à \(n\cdot(\delta x_s - \sum N_i\,\delta x_{m_i})\) ; seul le terme du second ordre, la raideur géométrique du contact, est négligé. update_contact(modele, 0) redonne l’appariement de construction. Le pas à pas met ses contacts à jour chaque fois qu’il reconstruit son opérateur.

Variables

primalelambda, le multiplicateur de contact (la réaction est \(-C^{\mathsf T}\lambda\))
dualeimposed
ce qu’il contraintles déplacements variables, aux nœuds esclaves et maîtres
matériau, comportementaucun

Ce qu’il produit

raideurune paire \(C\), \(C^{\mathsf T}\)
masse, géométrique—
tangente= raideur
forces internes\(C^{\mathsf T}\lambda\) sur les duales, \(C\,u\) sur les lignes imposed
forces externes\(-g_0\) sur les lignes imposed

Exemple

Deux blocs élastiques empilés, u_x bloqué partout, une pression S sur le bloc du haut : le contact se ferme et transmet \(\sigma_{yy} = -S\), les réactions \(-\lambda_i\) étant les forces nodales de la pression.

# Maître : le bord haut du bloc du bas, parcouru en −x (normale +y, vers l'esclave).
master = pyrucast.Mesh(c, "SEG2")
for i in reversed(range(N)):
    master.unit().add_cell([bottom[idx(i + 1, N)], bottom[idx(i, N)]])
# Esclave : les nœuds du bord bas du bloc du haut.
slave = pyrucast.mesh.poi1_from_nodes([top[idx(i, 0)] for i in range(N + 1)])

contact = pyrucast.model.contact(slave, master, elasticite, ["u_x", "u_y"])
# La pression sur le bord haut est un terme du modèle, comme le contact.
charge = pyrucast.model.flux(edge_fes, elasticite, "f_y")
model = elasticite | appuis | contact | charge
materials = pyrucast.element_field.material_field(
    model, [("E", 210.0), ("nu", 0.0), ("phi_f_y", -S)]
)

# La pression, et le jeu que porte le contact.
rhs = pyrucast.node_field.external_forces(model, materials)
solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), rhs)

Le jeu que porte le contact, ici nul :

# Le jeu est un chargement que porte le contact : seul, `external_forces` rend
# −g0 à chacun de ses nœuds multiplicateurs ; ici 0, les blocs se touchant.
gaps = pyrucast.node_field.external_forces(contact, materials)
mult_mesh = contact.multiplier_mesh()
for r in range(N + 1):
    assert gaps.value(mult_mesh.node(0, r, 0), "imposed") == 0.0

Le bloc du haut glissé d’un quart, et les contacts appariés de nouveau :

# Le bloc du haut a glissé d'un quart vers la droite : chaque nœud esclave est
# apparié de nouveau à sa position déplacée ; les multiplicateurs restent les mêmes.
glisse = pyrucast.NodeField(pyrucast.mesh.poi1_from_nodes(top), ["u_x", "u_y"])
for n in top:
    glisse[0].set_value(n, "u_x", 0.25)
nouveau = contact.update_contact(glisse)  # ou pyrucast.model.update_contact(…)
assert nouveau.multiplier_mesh().node(0, 0, 0).id == mult_mesh.node(0, 0, 0).id

Côté Rust

Contact (src/models/contact.rs) implémente Constraint et déclare son jeu comme force externe. tests/contact.rs et tests/python/test_contact.py déroulent les deux blocs en 2-D et en 3-D, et vérifient que soulever le bloc du haut relâche toutes les paires.

Éléments finis

Un type de maille ("TRI3", "HEX20"…) est aussi un élément fini : un élément de référence, des fonctions de forme qui interpolent une grandeur à partir de ses valeurs aux nœuds, et une règle d’intégration. Cette section en donne une fiche par type. La machinerie commune à tous les éléments (passage de l’élément de référence à la maille, jacobien, dérivées physiques) est décrite avec l’espace éléments finis.

En Python, un élément se lit sur un sous-espace : gauss_xi, gauss_weight, n_at_g et dn_at_g rendent ses tables de référence (voir Lire un espace). Elles ne dépendent que du type, de l’interpolation et de la quadrature : un espace posé sur une zone vide les donne déjà, et chaque fiche s’en sert dans son exemple.

De la fonction de forme à la matrice

Sur chaque maille, une grandeur est interpolée par ses valeurs nodales, \( u(\xi) = \sum_i N_i(\xi)\,u_i \), et la géométrie de la même façon, \( \mathbf{x}(\xi) = \sum_i N_i(\xi)\,\mathbf{x}_i \). Toute matrice élémentaire est une intégrale sur la maille, ramenée à l’élément de référence par le jacobien \( J = \partial\mathbf{x}/\partial\xi \) :

\[ \int_K \phi(\mathbf{x})\,d\mathbf{x} = \int_{\hat K} \phi(\mathbf{x}(\xi))\,|J(\xi)|\,d\xi \approx \sum_{g} w_g\,\phi(\xi_g)\,|J(\xi_g)| , \]

et les dérivées physiques viennent de l’inverse du jacobien, \( \nabla_x N_i = J^{-\mathsf T}\nabla_\xi N_i \). Ce qui change d’un élément à l’autre tient donc en trois choses : les \( N_i \), leurs dérivées \( \partial N_i/\partial\xi_k \), et les points et poids \( (\xi_g, w_g) \). C’est le contenu de chaque fiche.

Catalogue

Deux familles d’interpolation : Lagrange-1 ("LAGRANGE1", un nœud par sommet) et Lagrange-2 ("LAGRANGE2", sommets et milieux d’arête, plus des nœuds de face et de centre pour les éléments complets). POI1, un nœud seul, n’a pas d’élément de référence : ce n’est pas un élément fini.

Lagrange-1

ÉlémentNœudsDimensionÉlément de référence"GAUSS" : points, degré exact
SEG221\( \xi\in[-1,1] \)Gauss 2, degré 3
TRI332triangle \( \xi+\eta\le 1 \)Hammer 3, degré 2
QUA442\( [-1,1]^2 \)2×2, degré 3 par direction
TET443tétraèdre \( \xi+\eta+\zeta\le 1 \)Hammer 4, degré 2
PYRA553pyramide à base carréeconique 8, degré 3
PENTA663prisme (triangle × \( \zeta \))3 × 2 = 6, degré 2 dans le triangle, 3 en \( \zeta \)
HEX883\( [-1,1]^3 \)2×2×2, degré 3 par direction

Lagrange-2

ÉlémentNœudsDimensionParentType"GAUSS" : points, degré exact
SEG331SEG2completGauss 3, degré 5
TRI662TRI3completDunavant 6, degré 4
QUA882QUA4sérendipité3×3, degré 5 par direction
QUA992QUA4complet3×3, degré 5 par direction
TET10103TET4completKeast 11, degré 4
PENTA15153PENTA6sérendipité6 × 3 = 18, degré 4 dans le triangle, 5 en \( \zeta \)
HEX20203HEX8sérendipité3×3×3, degré 5 par direction
HEX27273HEX8complet3×3×3, degré 5 par direction

Les éléments sérendipité (QUA8, PENTA15, HEX20) n’ont que des nœuds aux sommets et aux milieux d’arête. Les complets ont en plus les nœuds qu’exige le polynôme de degré 2 entier : un nœud central pour QUA9, des centres de face et de volume pour HEX27. Le degré exact de chaque règle a été mesuré en intégrant les monômes \( \xi^a\eta^b\zeta^c \) avec les tables que rend Python.

Tous les types se lisent par read_gmsh et from_arrays. Chaque fiche dit quels mailleurs de pyrucast le produisent.

Les degrés de liberté

Un degré de liberté est un couple (nœud, inconnue) : la valeur de T au nœud 12, celle de u_x au nœud 3. Une maille de \( n \) nœuds, sous une physique de \( k \) inconnues, en porte \( n \times k \). Sa matrice élémentaire les range nœud par nœud : toutes les inconnues du nœud 0, puis celles du nœud 1, et ainsi de suite. Deux mailles voisines partagent les degrés de liberté de leurs nœuds communs : c’est ce qui rend le champ continu d’une maille à l’autre.

import pyrucast

# Un TRI3, une maille, en élasticité plane puis en conduction.
c = pyrucast.Coords(2)
maille = pyrucast.Mesh(c, "TRI3")
maille.unit().add_cell([c.add_node(p) for p in [[0, 0], [1, 0], [0, 1]]])
fes = pyrucast.FiniteElementSpace(maille)

solide = pyrucast.model.elasticity(fes, "plane_stress")
k = pyrucast.matrix.stiffness(
    solide, pyrucast.element_field.material_field(solide, [("E", 1.0), ("nu", 0.0)])
)
print(k.n_cols)  # 6 = 3 nœuds × 2 inconnues
print(k.col_dofs()[:4])  # [(0, 'u_x'), (0, 'u_y'), (1, 'u_x'), (1, 'u_y')]

chaleur = pyrucast.model.heat_conduction(fes)
k = pyrucast.matrix.stiffness(
    chaleur, pyrucast.element_field.material_field(chaleur, [("k", 1.0)])
)
print(k.col_dofs())  # [(0, 'T'), (1, 'T'), (2, 'T')]

Ce que chaque physique pose sur quels éléments :

physiqueélémentsinconnues par nœud
conduction, diffusionSEG2, SEG3 ; TRI3, QUA4, TRI6, QUA8, QUA9 ; TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 ; en volume ou plongés (un fil, une plaque mince)T ; c_<espèce>, une par espèce
échanges, rayonnementle bord d’une conduction : SEG2, SEG3 en 2-D ; TRI3, QUA4, TRI6, QUA8, QUA9 en 3-DT
élasticité et ses lois2-D : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27u_x, u_y ; et u_z en 3-D
barreSEG2un déplacement par dimension
poutresSEG2w, theta en 1-D ; u_x, u_y, r_z en plan ; six dans l’espace
coqueTRI3, QUA4, dans l’espaceu_x, u_y, u_z, r_x, r_y, r_z

L’élasticité refuse un élément plongé : des segments dans le plan, des triangles dans l’espace sont un bord, pas un solide. La poutre de Bernoulli interpole sa flèche avec "HERMITE3" : quatre fonctions sur un SEG2, une valeur et une pente par nœud, d’où w et theta (voir Fonctions de forme d’Hermite).

Les règles d’intégration

Une intégrale sur l’élément de référence est remplacée par une somme pondérée de valeurs en quelques points :

\[ \int_{\hat K} f(\xi)\,d\xi \approx \sum_{g=1}^{n_g} w_g\,f(\xi_g) . \]

Une règle est exacte au degré \( p \) quand cette somme donne l’intégrale exacte de tout polynôme de degré au plus \( p \). La somme des poids vaut toujours la mesure de l’élément de référence : c’est l’intégrale de la constante 1.

Sur un segment : Gauss-Legendre. Avec \( n \) points, positions et poids font \( 2n \) inconnues, de quoi rendre la somme exacte pour les \( 2n \) monômes \( 1, \xi, \dots, \xi^{2n-1} \). La solution place les points aux racines du polynôme de Legendre de degré \( n \) :

\( n \)points \( \xi_g \)poids \( w_g \)exacte au degré
2\( \pm 1/\sqrt3 \)\( 1, 1 \)3
3\( -\sqrt{3/5},\ 0,\ +\sqrt{3/5} \)\( 5/9,\ 8/9,\ 5/9 \)5

Sur \( [0, 1] \), la hauteur d’un prisme, les mêmes points sont ramenés par \( \zeta = (1 + \xi)/2 \), et les poids divisés par 2.

Quadrangle, hexaèdre, prisme : des produits. Le point \( (a, b) \) d’une règle produit est \( (\xi_a, \eta_b) \), de poids \( w_a w_b \). Elle intègre exactement \( \xi^p \eta^q \) dès que chaque exposant est dans la portée de la règle à une dimension : l’exactitude se compte par direction. Le prisme croise la règle du triangle et Gauss-Legendre en \( \zeta \).

Triangle, tétraèdre : des règles symétriques. Un simplexe n’est pas un produit. Ses règles sont des tables : des points et des poids calculés une fois pour rendre la somme exacte jusqu’à un degré donné. Les points y vont par familles, chaque famille réunissant les images d’un point par permutation des coordonnées barycentriques, avec un même poids : la règle ne favorise aucun sommet.

élémentrèglefamillesdegré
TRI3Hammer3 milieux de côté2
TET4Hammer4 points, un vers chaque sommet2
TRI6Dunavantdeux familles de 34
TET10Keastle centre, une famille de 4, une de 64

Le centre de la règle de Keast a un poids négatif, \( -74/5625 \) : la règle reste exacte au degré 4, mais elle n’est pas une moyenne pondérée de valeurs positives.

Pyramide : un produit conique. La section carrée de la pyramide rétrécit jusqu’au sommet. En écrivant \( \xi = a(1-\zeta) \), \( \eta = b(1-\zeta) \), elle devient un produit : Gauss-Legendre 2×2 sur \( (a, b) \), et en \( \zeta \) une règle de Gauss-Jacobi qui absorbe le facteur \( (1-\zeta)^2 \) du rétrécissement. Le détail est sur la fiche PYRA5.

Le degré nécessaire. La masse \( \int \rho\,N_i N_j \) est un produit de deux fonctions de forme : de degré 2 pour un élément linéaire, 4 pour un quadratique (par direction pour les produits). La raideur \( \int \nabla N_i \cdot D\,\nabla N_j \) est de degré moindre. La règle "GAUSS" de chaque type intègre exactement les deux quand la maille est l’image de l’élément de référence par une transformation affine (côtés droits, faces en parallélogramme) et que le matériau est constant : \( J \) est alors constant, et l’intégrande reste un polynôme. Sur une maille courbe ou déformée, \( J^{-1} \) n’est plus un polynôme, et aucune règle n’intègre exactement la raideur.

La règle "REDUCED" n’a qu’un point, au centre de gravité de l’élément de référence, de poids égal à sa mesure. Elle intègre exactement les fonctions affines. Elle porte une valeur par maille (element_field.cell_integral), et les coques épaisses y intègrent leur cisaillement transverse.

# Un point au centre, de poids la mesure de l'élément de référence.
centre = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(3), "PYRA5"), quadrature="REDUCED"
)[0]
print(centre.gauss_count, centre.gauss_xi(0))  # 1 [0.0, 0.0, 0.25]
print(round(centre.gauss_weight(0), 6))  # 1.333333 = 4/3, le volume de la pyramide

Propriétés communes

Toutes les interpolations de Lagrange vérifient, en tout point de référence :

  • Kronecker : \( N_i(\xi_j) = \delta_{ij} \) aux nœuds, si bien que \( u_i \) est la valeur du champ au nœud \( i \) ;
  • partition de l’unité : \( \sum_i N_i = 1 \), d’où un champ constant reproduit exactement ;
  • dérivées de somme nulle : \( \sum_i \partial N_i/\partial\xi_k = 0 \).

Les fonctions des types quadratiques prennent des valeurs négatives dans la maille : celle d’un sommet de TET10 vaut \( -1/8 \) au centre.

Le plan des fiches

Chaque fiche suit le même plan :

  • En bref : le nom Python, les nœuds, l’interpolation, la dimension, le bord, la quadrature, le type apparenté et les mailleurs qui le produisent ;
  • Élément de référence : son domaine et la position de chaque nœud ;
  • Fonctions de forme et Dérivées de référence ;
  • Degrés de liberté : les inconnues qu’il porte sous chaque physique ;
  • Quadrature : les points et les poids de "GAUSS", et ce qu’elle intègre exactement ;
  • Exemple : ses tables de référence, lues en Python ;
  • Côté Rust.

Côté Rust

ElementType (src/atoms/element_type.rs) énumère les types ; ElementType::as_kind rend l’implémentation de ElementKind (src/atoms/element_kind/mod.rs) propre à chacun. Un élément tient dans un fichier, src/atoms/element_kind/<nom>.rs : nœuds de référence, fonctions de forme et dérivées (shape_into, dshape_into), règle de Gauss (gauss), faces et arêtes, domaine de référence (contains_ref, clamp_ref), codes d’échange VTK et gmsh, type quadratique ou parent linéaire. La règle réduite n’est écrite nulle part : c’est la méthode par défaut reduced, un point en ref_centroid de poids ref_measure.

Interpolation et QuadratureRule (src/atoms/element_kind/) sont des étiquettes : les tables vivent dans chaque élément. Les tests de ce dossier vérifient, pour tous les types, Kronecker, la partition de l’unité, la somme des poids, les faces et les arêtes, et pour les types quadratiques et la pyramide, les dérivées contre des différences finies ; ceux de quadrature.rs intègrent des monômes pour SEG2, TRI3, TRI6 et TET10.

SEG2 — segment linéaire

En bref

Un segment à deux nœuds : une barre, une poutre, un fil conducteur, le bord d’une surface.

nom"SEG2"
nœuds2, aux extrémités
interpolation"LAGRANGE1" : linéaire ; aussi "HERMITE3" et "MODEL_EMBEDDED" pour les poutres
dimension1 ; dans des Coords 1-D, 2-D ou 3-D
bordses deux extrémités
quadrature"GAUSS" : 2 points, degré 3 ; "REDUCED" : 1 point
version quadratiqueSEG3
obtenu parline, arc, circle ; border, le bord d’une surface TRI3 ou QUA4

Élément de référence

\( \xi \in [-1, +1] \).

Nœud\( \xi \)
0\( -1 \)
1\( +1 \)

Fonctions de forme

\[ N_0(\xi) = \tfrac{1}{2}(1 - \xi), \qquad N_1(\xi) = \tfrac{1}{2}(1 + \xi). \]

Dans des Coords 2-D ou 3-D, le segment est plongé : sa longueur s’obtient par la métrique \( \sqrt{J^{\mathsf T}J} \), et le gradient d’un champ est le gradient le long du segment (voir Jacobien et grandeurs physiques).

Dérivées de référence

Constantes :

\[ \frac{\partial N_0}{\partial \xi} = -\tfrac{1}{2}, \qquad \frac{\partial N_1}{\partial \xi} = +\tfrac{1}{2}. \]

Sur un segment de longueur \( L \), \( |J| = L/2 \) : un gradient constant le long du segment.

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT2
échanges, rayonnement, sur le bord d’une surfaceT2
barreu_x en 1-D ; u_x, u_y en plan ; u_x, u_y, u_z dans l’espace2, 4, 6
poutre de Bernoulli ("HERMITE3"), de Timoshenko ("MODEL_EMBEDDED")w, theta en 1-D ; u_x, u_y, r_z en plan ; six dans l’espace4, 6, 12

Sous "HERMITE3", la flèche est interpolée par quatre fonctions cubiques, une valeur et une pente par nœud. Sous "MODEL_EMBEDDED", la poutre porte ses propres fonctions, et l’espace n’en tabule aucune pour le champ.

Quadrature

"GAUSS" : Gauss-Legendre à 2 points, exacte au degré 3.

\[ \xi_g = \pm\frac{1}{\sqrt 3}, \qquad w_g = 1, \qquad \sum_g w_g = 2. \]

La masse d’un segment droit, \( \int N_i N_j\,ds = \tfrac{L}{6}\begin{bmatrix}2&1\\1&2\end{bmatrix} \), est de degré 2 : intégrée exactement. Sous "HERMITE3", la raideur de flexion l’est aussi : ses dérivées secondes sont linéaires, leur produit de degré 2.

"REDUCED" : un point en \( \xi = 0 \), de poids 2.

Exemple

import pyrucast

# Les tables de référence d'un SEG2 : une zone vide suffit.
seg2 = pyrucast.FiniteElementSpace(pyrucast.Mesh(pyrucast.Coords(1), "SEG2"))[0]
print(seg2.nodes_per_cell, seg2.gauss_count)  # 2 2

# Les deux points en ∓1/√3, de poids 1.
print([round(seg2.gauss_xi(g)[0], 6) for g in range(2)])  # [-0.57735, 0.57735]
print([seg2.gauss_weight(g) for g in range(2)])  # [1.0, 1.0]
# N = ((1 − ξ)/2, (1 + ξ)/2) au premier point ; les dérivées, constantes.
print([round(v, 6) for v in seg2.n_at_g(0)])  # [0.788675, 0.211325]
print(seg2.dn_at_g(0))  # [-0.5, 0.5]

Côté Rust

Seg2 (src/atoms/element_kind/seg2.rs) implémente ElementKind. La base d’Hermite vit à part, dans src/atoms/element_kind/hermite.rs : seul un SEG2 l’accepte.

TRI3 — triangle linéaire

En bref

Le triangle à trois nœuds, l’élément 2-D le plus simple : son interpolation est affine, et un gradient y est constant. Plongé dans l’espace, il décrit une surface : une coque, la peau d’un volume.

nom"TRI3"
nœuds3, aux sommets
interpolation"LAGRANGE1" : affine
dimension2 ; dans des Coords 2-D ou 3-D
bord3 SEG2
quadrature"GAUSS" : 3 points, degré 2 ; "REDUCED" : 1 point
version quadratiqueTRI6
obtenu partriangulate_surface, sweep, transfinite ; convert d’un QUA4 ; skin d’un TET4, PYRA5 ou PENTA6

Élément de référence

Le triangle \( \xi, \eta \ge 0 \), \( \xi + \eta \le 1 \), parcouru dans le sens trigonométrique.

Nœud\( (\xi, \eta) \)
0\( (0, 0) \)
1\( (1, 0) \)
2\( (0, 1) \)

Fonctions de forme

Les coordonnées barycentriques du point :

\[ N_0 = 1 - \xi - \eta, \qquad N_1 = \xi, \qquad N_2 = \eta. \]

Dérivées de référence

Constantes :

\[ \nabla_\xi N_0 = (-1, -1), \qquad \nabla_\xi N_1 = (1, 0), \qquad \nabla_\xi N_2 = (0, 1). \]

Le jacobien est donc constant sur la maille, comme le gradient d’un champ et la déformation d’un solide.

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT3
élasticité plane ou axisymétriqueu_x, u_y6
coque, dans l’espaceu_x, u_y, u_z, r_x, r_y, r_z18
échanges, rayonnement, sur la face d’un volumeT3

Quadrature

"GAUSS" : règle de Hammer à 3 points, aux milieux des côtés, exacte au degré 2.

\[ \xi_g \in \left\{ \left(\tfrac12, 0\right), \left(\tfrac12, \tfrac12\right), \left(0, \tfrac12\right) \right\}, \qquad w_g = \tfrac{1}{6}, \qquad \sum_g w_g = \tfrac12. \]

Un point suffirait à la raideur, de degré 0 ; la masse \( \int N_i N_j \), de degré 2, en demande trois.

"REDUCED" : un point au centre de gravité \( (\tfrac13, \tfrac13) \), de poids \( \tfrac12 \).

Exemple

import pyrucast

# Les tables de référence d'un TRI3 : une zone vide suffit.
tri3 = pyrucast.FiniteElementSpace(pyrucast.Mesh(pyrucast.Coords(2), "TRI3"))[0]
print(tri3.nodes_per_cell, tri3.gauss_count)  # 3 3

# Le point 0, au milieu du côté (0, 1), de poids 1/6.
print(tri3.gauss_xi(0), round(tri3.gauss_weight(0), 6))  # [0.5, 0.0] 0.166667
# N = (1 − ξ − η, ξ, η) en ce point.
print(tri3.n_at_g(0))  # [0.5, 0.5, 0.0]
# Les dérivées, les mêmes en tout point : (−1, −1), (1, 0), (0, 1).
print(tri3.dn_at_g(0))  # [-1.0, -1.0, 1.0, 0.0, 0.0, 1.0]

Côté Rust

Tri3 (src/atoms/element_kind/tri3.rs) implémente ElementKind.

QUA4 — quadrangle bilinéaire

En bref

Le quadrangle à quatre nœuds. Son interpolation est bilinéaire : elle contient le terme \( \xi\eta \) qui manque au triangle, si bien qu’un gradient y varie.

nom"QUA4"
nœuds4, aux sommets
interpolation"LAGRANGE1" : bilinéaire
dimension2 ; dans des Coords 2-D ou 3-D
bord4 SEG2
quadrature"GAUSS" : 2×2 points, degré 3 par direction ; "REDUCED" : 1 point
versions quadratiquesQUA8 (sérendipité), QUA9 (complet)
obtenu parsweep, transfinite, pave_surface, grid_surface, triangulate_surface ; extrude, revolve d’un SEG2 ; skin d’un HEX8, PYRA5 ou PENTA6

Élément de référence

Le carré \( \xi, \eta \in [-1, +1] \), parcouru dans le sens trigonométrique.

Nœud\( (\xi_i, \eta_i) \)
0\( (-1, -1) \)
1\( (+1, -1) \)
2\( (+1, +1) \)
3\( (-1, +1) \)

Fonctions de forme

Pour le nœud \( i \) de coordonnées \( (\xi_i, \eta_i) \) :

\[ N_i(\xi, \eta) = \tfrac{1}{4}\,(1 + \xi_i\,\xi)\,(1 + \eta_i\,\eta). \]

Chacune est le produit d’une fonction linéaire en \( \xi \) et d’une autre en \( \eta \) : le quadrangle reproduit exactement \( a + b\xi + c\eta + d\xi\eta \).

Dérivées de référence

\[ \frac{\partial N_i}{\partial \xi} = \tfrac14\,\xi_i\,(1 + \eta_i\,\eta), \qquad \frac{\partial N_i}{\partial \eta} = \tfrac14\,\eta_i\,(1 + \xi_i\,\xi). \]

\( \partial N_i/\partial\xi \) varie avec \( \eta \) : sauf sur un parallélogramme, le jacobien n’est pas constant sur la maille.

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT4
élasticité plane ou axisymétriqueu_x, u_y8
coque, dans l’espaceu_x, u_y, u_z, r_x, r_y, r_z24
échanges, rayonnement, sur la face d’un volumeT4

Quadrature

"GAUSS" : produit 2×2 de Gauss-Legendre, exact au degré 3 par direction.

\[ \xi_g = \left(\pm\tfrac{1}{\sqrt 3}, \pm\tfrac{1}{\sqrt 3}\right), \qquad w_g = 1, \qquad \sum_g w_g = 4. \]

La masse, de degré 2 par direction, est exacte sur un parallélogramme. Sur un quadrangle quelconque, le jacobien varie, et la raideur ne l’est plus.

"REDUCED" : un point au centre \( (0, 0) \), de poids 4.

Exemple

import pyrucast

qua4 = pyrucast.FiniteElementSpace(pyrucast.Mesh(pyrucast.Coords(2), "QUA4"))[0]
print(qua4.nodes_per_cell, qua4.gauss_count)  # 4 4

# Le point 0 en (−1/√3, −1/√3), de poids 1.
print([round(x, 6) for x in qua4.gauss_xi(0)], qua4.gauss_weight(0))
# [-0.57735, -0.57735] 1.0
# N_i = ¼ (1 + ξ_i ξ)(1 + η_i η) : le nœud 0, le plus proche, pèse le plus.
print([round(v, 6) for v in qua4.n_at_g(0)])  # [0.622008, 0.166667, 0.044658, 0.166667]

Côté Rust

Qua4 (src/atoms/element_kind/qua4.rs) implémente ElementKind.

TET4 — tétraèdre linéaire

En bref

Le tétraèdre à quatre nœuds, pendant 3-D du TRI3 : interpolation affine, gradient constant dans la maille. Il remplit n’importe quel volume, et c’est ce que produisent les mailleurs de volume. Une déformation constante par maille le rend raide en flexion : il y faut des mailles fines, ou le TET10.

nom"TET4"
nœuds4, aux sommets
interpolation"LAGRANGE1" : affine
dimension3 ; dans des Coords 3-D
bord4 TRI3
quadrature"GAUSS" : 4 points, degré 2 ; "REDUCED" : 1 point
version quadratiqueTET10
obtenu partriangulate_volume, pave_volume ; convert d’un HEX8

Élément de référence

Le tétraèdre \( \xi, \eta, \zeta \ge 0 \), \( \xi + \eta + \zeta \le 1 \). La face 0-1-2 tourne dans le sens trigonométrique vue depuis le nœud 3.

Nœud\( (\xi, \eta, \zeta) \)
0\( (0, 0, 0) \)
1\( (1, 0, 0) \)
2\( (0, 1, 0) \)
3\( (0, 0, 1) \)

Fonctions de forme

Les coordonnées barycentriques :

\[ N_0 = 1 - \xi - \eta - \zeta, \quad N_1 = \xi, \quad N_2 = \eta, \quad N_3 = \zeta. \]

Dérivées de référence

Constantes :

\[ \nabla_\xi N_0 = (-1,-1,-1), \quad \nabla_\xi N_1 = (1,0,0), \quad \nabla_\xi N_2 = (0,1,0), \quad \nabla_\xi N_3 = (0,0,1). \]

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT4
élasticité 3-Du_x, u_y, u_z12

Quadrature

"GAUSS" : règle de Hammer à 4 points, exacte au degré 2. Avec \( \alpha = \tfrac{5 - \sqrt5}{20} \) et \( \beta = \tfrac{5 + 3\sqrt5}{20} \), les points sont \( (\alpha, \alpha, \alpha) \) et les trois permutations de \( (\beta, \alpha, \alpha) \) : chacun a la coordonnée barycentrique \( \beta \) vers un sommet, \( \alpha \) vers les trois autres.

\[ w_g = \tfrac{1}{24}, \qquad \sum_g w_g = \tfrac{1}{6}. \]

La masse, de degré 2, est exacte ; la raideur, de degré 0, aussi.

"REDUCED" : un point au centre de gravité \( (\tfrac14, \tfrac14, \tfrac14) \), de poids \( \tfrac16 \).

Exemple

import pyrucast

tet4 = pyrucast.FiniteElementSpace(pyrucast.Mesh(pyrucast.Coords(3), "TET4"))[0]
print(tet4.nodes_per_cell, tet4.gauss_count)  # 4 4

# Le point 0 en (α, α, α), α = (5 − √5)/20, de poids 1/24.
print([round(x, 6) for x in tet4.gauss_xi(0)], round(tet4.gauss_weight(0), 6))
# [0.138197, 0.138197, 0.138197] 0.041667
# N = (1 − ξ − η − ζ, ξ, η, ζ) : β au nœud 0, α aux trois autres.
print([round(v, 6) for v in tet4.n_at_g(0)])  # [0.58541, 0.138197, 0.138197, 0.138197]

Côté Rust

Tet4 (src/atoms/element_kind/tet4.rs) implémente ElementKind.

PYRA5 — pyramide linéaire

En bref

La pyramide à base carrée relie des hexaèdres à des tétraèdres : sa face carrée s’appuie sur une face de HEX8, ses quatre faces triangulaires sur des faces de TET4. Une couche d’hexaèdres se referme ainsi sur un cœur de tétraèdres sans nœud isolé au milieu d’une face.

nom"PYRA5"
nœuds5 : les quatre coins de la base, le sommet
interpolation"LAGRANGE1" : bilinéaire sur chaque section, rationnelle
dimension3 ; dans des Coords 3-D
bord1 QUA4, 4 TRI3
quadrature"GAUSS" : 8 points, degré 3 ; "REDUCED" : 1 point
version quadratiqueaucune : to_quadratic refuse une pyramide
obtenu parpave_volume

Élément de référence

\( \zeta \in [0, 1] \), et \( \xi, \eta \in [-(1-\zeta),\ +(1-\zeta)] \) : la section carrée rétrécit avec \( \zeta \) jusqu’au sommet. La base tourne dans le sens trigonométrique vue depuis le sommet.

Nœud\( (\xi, \eta, \zeta) \)Nœud\( (\xi, \eta, \zeta) \)
0\( (-1, -1, 0) \)3\( (-1, 1, 0) \)
1\( (1, -1, 0) \)4\( (0, 0, 1) \)
2\( (1, 1, 0) \)

Fonctions de forme

Ses fonctions ne sont pas polynomiales : la base carrée doit se réduire à un point au sommet, et aucun polynôme ne le fait en restant bilinéaire sur la base. Avec \( m = 1 - \zeta \), la demi-largeur de la section, ce sont les fonctions bilinéaires du carré dans les coordonnées \( \xi/m \), \( \eta/m \), multipliées par \( m \) :

\[ N_i = \frac{m}{4}\left(1 + \xi_i\,\frac{\xi}{m}\right)\left(1 + \eta_i\,\frac{\eta}{m}\right) \quad (i = 0 \dots 3), \qquad N_4 = \zeta, \]

où \( (\xi_i, \eta_i) \) sont les signes du nœud \( i \). Développé,

\[ N_i = \frac{1}{4}\left(m + \xi_i\,\xi + \eta_i\,\eta + \xi_i\eta_i\,\frac{\xi\eta}{m}\right). \]

Le terme \( \xi\eta/m \) est la partie rationnelle. Il reste borné dans l’élément, \( |\xi\eta/m| \le m \), mais n’est pas défini au sommet, où le code pose directement \( N_4 = 1 \) et les autres à 0.

Sur la base, \( \zeta = 0 \), les fonctions sont exactement celles d’un QUA4 ; le long d’une arête de la base au sommet, elles sont linéaires. C’est ce qui raccorde la pyramide sans trou à un HEX8 par sa face carrée, et à un TET4 par une face triangulaire.

Dérivées de référence

Avec \( u = \xi/m \), \( v = \eta/m \) :

\[ \frac{\partial N_i}{\partial \xi} = \frac{\xi_i}{4}\,(1 + \eta_i v), \qquad \frac{\partial N_i}{\partial \eta} = \frac{\eta_i}{4}\,(1 + \xi_i u), \qquad \frac{\partial N_i}{\partial \zeta} = \frac{1}{4}\,(-1 + \xi_i\eta_i\,u v), \]

et \( \nabla_\xi N_4 = (0, 0, 1) \).

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT5
élasticité 3-Du_x, u_y, u_z15

Quadrature

La pyramide n’est le produit d’aucune paire d’éléments plus simples. Sa règle "GAUSS" est conique : on écrit un point \( \xi = a(1-\zeta) \), \( \eta = b(1-\zeta) \), avec \( a, b \in [-1, 1] \), et le changement de variables donne

\[ d\xi\,d\eta = (1-\zeta)^2\,da\,db , \]

le rétrécissement de la section. La règle croise Gauss-Legendre 2×2 sur \( (a, b) \) et, en \( \zeta \), une règle de Gauss-Jacobi à 2 points, faite pour intégrer contre ce poids \( (1-\zeta)^2 \). Ses deux points sont les racines de \( z^2 - \tfrac23 z + \tfrac1{15} \),

\[ \zeta_g = \frac13 \mp \frac{\sqrt{10}}{15} \simeq 0{,}12251 \ \text{et}\ 0{,}54415, \]

et ses poids, \( 0{,}23255 \) et \( 0{,}10079 \), se déduisent des deux premiers moments de \( (1-z)^2 \) sur \( [0,1] \) : \( \sum w = 1/3 \), \( \sum w z = 1/12 \). Soit 8 points, et une somme des poids \( 2 \times 2 \times \tfrac13 = \tfrac43 \), le volume de la pyramide de référence.

Exprimées en \( (a, b, \zeta) \), les fonctions de forme redeviennent des polynômes : la règle intègre exactement les monômes jusqu’au degré 3, et la masse et la raideur de toute pyramide dont la base est un parallélogramme. Sur une base quelconque, la raideur n’est plus exacte, comme sur un QUA4 ou un HEX8 déformé.

"REDUCED" : un point au centre de gravité \( (0, 0, \tfrac14) \), au quart de la hauteur, de poids \( \tfrac43 \).

Exemple

import pyrucast

pyra5 = pyrucast.FiniteElementSpace(pyrucast.Mesh(pyrucast.Coords(3), "PYRA5"))[0]
print(pyra5.nodes_per_cell, pyra5.gauss_count)  # 5 8

# Deux étages en ζ = 1/3 ∓ √10/15 ; la section rétrécit vers le sommet.
for g in (0, 1):
    print([round(x, 6) for x in pyra5.gauss_xi(g)], round(pyra5.gauss_weight(g), 6))
# [-0.506616, -0.506616, 0.122515] 0.232547
# [-0.263184, -0.263184, 0.544152] 0.100786
print(round(sum(pyra5.gauss_weight(g) for g in range(8)), 12))  # 1.333333333333 = 4/3

# Le sommet ne porte que ζ : N_4 vaut la hauteur du point.
print(round(pyra5.n_at_g(0)[4], 6))  # 0.122515

Côté Rust

Pyra5 (src/atoms/element_kind/pyra5.rs) implémente ElementKind. Au sommet, shape_into et dshape_into rendent des valeurs fixées au lieu de diviser par \( m \). pyra5_jacobian_volume, dans src/containers/finite_element_space/, vérifie le volume d’une pyramide à base rectangulaire et à sommet décentré.

PENTA6 — prisme linéaire

En bref

Le prisme à six nœuds, un triangle étiré dans une direction. C’est ce que donne un maillage de triangles extrudé, ou tourné autour d’un axe : un maillage par couches.

nom"PENTA6"
nœuds6 : le triangle du bas, puis celui du haut
interpolation"LAGRANGE1" : affine dans le triangle, linéaire en hauteur
dimension3 ; dans des Coords 3-D
bord2 TRI3, 3 QUA4
quadrature"GAUSS" : 3 × 2 = 6 points ; "REDUCED" : 1 point
version quadratiquePENTA15
obtenu parextrude, revolve d’un TRI3 ; sweep_solid ; pave_volume

Élément de référence

Le triangle \( \xi, \eta \ge 0 \), \( \xi + \eta \le 1 \), étiré sur \( \zeta \in [0, 1] \) : le triangle du bas (\( \zeta = 0 \)) puis celui du haut (\( \zeta = 1 \)), chacun dans le sens trigonométrique.

Nœud\( (\xi, \eta, \zeta) \)Nœud\( (\xi, \eta, \zeta) \)
0\( (0, 0, 0) \)3\( (0, 0, 1) \)
1\( (1, 0, 0) \)4\( (1, 0, 1) \)
2\( (0, 1, 0) \)5\( (0, 1, 1) \)

Fonctions de forme

Le produit des coordonnées barycentriques du triangle, \( L_1 = 1-\xi-\eta \), \( L_2 = \xi \), \( L_3 = \eta \), et d’une fonction linéaire en \( \zeta \) :

\[ N_{j-1} = L_j\,(1 - \zeta), \qquad N_{j+2} = L_j\,\zeta \qquad (j = 1, 2, 3). \]

Dérivées de référence

Les dérivées des \( L_j \) sont constantes ; celle en \( \zeta \) vient du facteur de hauteur. Pour le nœud 0 :

\[ \nabla_\xi N_0 = \big(-(1-\zeta),\ -(1-\zeta),\ -L_1\big). \]

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT6
élasticité 3-Du_x, u_y, u_z18

Quadrature

"GAUSS" : le produit de la règle du TRI3 (3 points, poids \( \tfrac16 \)) et de Gauss-Legendre à 2 points sur \( [0, 1] \) (\( \zeta_g = \tfrac12 \mp \tfrac{1}{2\sqrt3} \), poids \( \tfrac12 \)), soit 6 points.

\[ w_g = \tfrac16\cdot\tfrac12 = \tfrac{1}{12}, \qquad \sum_g w_g = \tfrac12. \]

Exacte au degré 2 dans le triangle et 3 en hauteur : la masse et la raideur sont exactes sur un prisme dont le triangle du haut est celui du bas déplacé.

"REDUCED" : un point en \( (\tfrac13, \tfrac13, \tfrac12) \), de poids \( \tfrac12 \).

Exemple

import pyrucast

penta6 = pyrucast.FiniteElementSpace(pyrucast.Mesh(pyrucast.Coords(3), "PENTA6"))[0]
print(penta6.nodes_per_cell, penta6.gauss_count)  # 6 6

# Les points du TRI3, à deux hauteurs ζ = ½ ∓ 1/(2√3), de poids 1/12.
print([round(x, 6) for x in penta6.gauss_xi(0)], round(penta6.gauss_weight(0), 6))
# [0.5, 0.0, 0.211325] 0.083333
# N = L_j (1 − ζ) en bas, L_j ζ en haut.
print([round(v, 6) for v in penta6.n_at_g(0)])
# [0.394338, 0.394338, 0.0, 0.105662, 0.105662, 0.0]

Côté Rust

Penta6 (src/atoms/element_kind/penta6.rs) implémente ElementKind.

HEX8 — hexaèdre trilinéaire

En bref

L’hexaèdre à huit nœuds, pendant 3-D du QUA4. Son interpolation trilinéaire le rend bien meilleur en flexion qu’un TET4, mais il demande un maillage structuré, par extrusion ou par couches.

nom"HEX8"
nœuds8, aux sommets
interpolation"LAGRANGE1" : trilinéaire
dimension3 ; dans des Coords 3-D
bord6 QUA4
quadrature"GAUSS" : 2×2×2 points, degré 3 par direction ; "REDUCED" : 1 point
versions quadratiquesHEX20 (sérendipité), HEX27 (complet)
obtenu parextrude, revolve d’un QUA4 ; sweep_solid ; pave_volume

Élément de référence

Le cube \( \xi, \eta, \zeta \in [-1, +1]^3 \) : la face du bas (nœuds 0 à 3) puis celle du haut (nœuds 4 à 7), chacune dans le sens trigonométrique.

Nœud\( (\xi_i, \eta_i, \zeta_i) \)Nœud\( (\xi_i, \eta_i, \zeta_i) \)
0\( (-1,-1,-1) \)4\( (-1,-1,+1) \)
1\( (+1,-1,-1) \)5\( (+1,-1,+1) \)
2\( (+1,+1,-1) \)6\( (+1,+1,+1) \)
3\( (-1,+1,-1) \)7\( (-1,+1,+1) \)

Fonctions de forme

Pour le nœud \( i \) de coordonnées \( (\xi_i, \eta_i, \zeta_i) \) :

\[ N_i(\xi, \eta, \zeta) = \tfrac{1}{8}\,(1 + \xi_i\,\xi)\,(1 + \eta_i\,\eta)\,(1 + \zeta_i\,\zeta). \]

Dérivées de référence

\[ \frac{\partial N_i}{\partial \xi} = \tfrac18\,\xi_i\,(1 + \eta_i\,\eta)(1 + \zeta_i\,\zeta), \]

et de même en \( \eta \) et \( \zeta \).

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT8
élasticité 3-Du_x, u_y, u_z24

Quadrature

"GAUSS" : produit 2×2×2 de Gauss-Legendre, exact au degré 3 par direction.

\[ \xi_g = \left(\pm\tfrac{1}{\sqrt 3}, \pm\tfrac{1}{\sqrt 3}, \pm\tfrac{1}{\sqrt 3}\right), \qquad w_g = 1, \qquad \sum_g w_g = 8. \]

La masse et la raideur d’un parallélépipède sont exactes ; celles d’un hexaèdre déformé, comme pour le QUA4, ne le sont plus.

"REDUCED" : un point au centre, de poids 8.

Exemple

import pyrucast

hex8 = pyrucast.FiniteElementSpace(pyrucast.Mesh(pyrucast.Coords(3), "HEX8"))[0]
print(hex8.nodes_per_cell, hex8.gauss_count)  # 8 8

# Le point 0 en (−1/√3, −1/√3, −1/√3), de poids 1.
print([round(x, 6) for x in hex8.gauss_xi(0)], hex8.gauss_weight(0))
# [-0.57735, -0.57735, -0.57735] 1.0
# Le nœud 0, le plus proche, et le nœud 6, à l'opposé.
n = hex8.n_at_g(0)
print(round(n[0], 6), round(n[6], 6))  # 0.490563 0.009437

Côté Rust

Hex8 (src/atoms/element_kind/hex8.rs) implémente ElementKind.

SEG3 — segment quadratique

En bref

Le segment à trois nœuds, un au milieu : l’interpolation est parabolique, et le segment peut être courbe. C’est le bord d’une surface quadratique.

nom"SEG3"
nœuds3 : les extrémités, puis le milieu
interpolation"LAGRANGE2" : quadratique
dimension1 ; dans des Coords 1-D, 2-D ou 3-D
bordses deux extrémités
quadrature"GAUSS" : 3 points, degré 5 ; "REDUCED" : 1 point
parent linéaireSEG2
obtenu parline, arc, circle avec element_type="SEG3" ; to_quadratic

Élément de référence

\( \xi \in [-1, +1] \), le nœud milieu en \( \xi = 0 \).

Nœud\( \xi \)rôle
0\( -1 \)extrémité
1\( +1 \)extrémité
2\( 0 \)milieu de \( (0,1) \)

Fonctions de forme

\[ N_0 = \tfrac12\,\xi(\xi - 1), \qquad N_1 = \tfrac12\,\xi(\xi + 1), \qquad N_2 = 1 - \xi^2. \]

La géométrie s’interpole de même : un nœud milieu hors de la corde courbe le segment en arc de parabole.

Dérivées de référence

\[ \frac{\partial N_0}{\partial \xi} = \xi - \tfrac12, \qquad \frac{\partial N_1}{\partial \xi} = \xi + \tfrac12, \qquad \frac{\partial N_2}{\partial \xi} = -2\xi. \]

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT3
échanges, rayonnement, sur le bord d’une surface quadratiqueT3

Quadrature

"GAUSS" : Gauss-Legendre à 3 points, exacte au degré 5.

\[ \xi_g = \left(-\sqrt{\tfrac35},\ 0,\ +\sqrt{\tfrac35}\right), \qquad w_g = \left(\tfrac59,\ \tfrac89,\ \tfrac59\right), \qquad \sum_g w_g = 2. \]

La masse, de degré 4, est exacte sur un segment droit dont le nœud milieu est au milieu.

"REDUCED" : un point en \( \xi = 0 \), de poids 2.

Exemple

import pyrucast

# Un type quadratique demande l'interpolation de degré 2.
seg3 = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(1), "SEG3"), interpolation="LAGRANGE2"
)[0]
print(seg3.nodes_per_cell, seg3.gauss_count)  # 3 3

# Les trois points en −√(3/5), 0, +√(3/5), de poids 5/9, 8/9, 5/9.
print([round(seg3.gauss_xi(g)[0], 6) for g in range(3)])  # [-0.774597, 0.0, 0.774597]
poids = [round(seg3.gauss_weight(g), 6) for g in range(3)]
print(poids)  # [0.555556, 0.888889, 0.555556]
# Au point du milieu, ξ = 0, seule la fonction du nœud 2 est non nulle.
print([i for i, v in enumerate(seg3.n_at_g(1)) if v != 0.0])  # [2]
# Les dérivées (ξ − ½, ξ + ½, −2ξ) au premier point.
print([round(v, 6) for v in seg3.dn_at_g(0)])  # [-1.274597, -0.274597, 1.549193]

Côté Rust

Seg3 (src/atoms/element_kind/seg3.rs) implémente ElementKind.

TRI6 — triangle quadratique

En bref

Le triangle à six nœuds, trois aux sommets et trois aux milieux des côtés. L’interpolation est quadratique : la déformation varie linéairement dans la maille, et les côtés peuvent être courbes. Bien plus précis qu’un TRI3 en flexion.

nom"TRI6"
nœuds6 : les sommets, puis les milieux des côtés
interpolation"LAGRANGE2" : quadratique complète
dimension2 ; dans des Coords 2-D ou 3-D
bord3 SEG3
quadrature"GAUSS" : 6 points, degré 4 ; "REDUCED" : 1 point
parent linéaireTRI3
obtenu parsweep, transfinite ; to_quadratic d’un TRI3 ; skin d’un TET10 ou PENTA15

Élément de référence

Le triangle du TRI3, avec ses coordonnées barycentriques \( L_1 = 1-\xi-\eta \), \( L_2 = \xi \), \( L_3 = \eta \).

Nœud\( (\xi, \eta) \)rôle
0\( (0, 0) \)sommet
1\( (1, 0) \)sommet
2\( (0, 1) \)sommet
3\( (\tfrac12, 0) \)milieu de \( (0,1) \)
4\( (\tfrac12, \tfrac12) \)milieu de \( (1,2) \)
5\( (0, \tfrac12) \)milieu de \( (2,0) \)

Fonctions de forme

\( L_i(2L_i - 1) \) aux sommets, \( 4 L_a L_b \) aux milieux :

\[ \begin{aligned} N_0 &= L_1(2L_1 - 1), & N_1 &= L_2(2L_2 - 1), & N_2 &= L_3(2L_3 - 1), \\ N_3 &= 4 L_1 L_2, & N_4 &= 4 L_2 L_3, & N_5 &= 4 L_3 L_1. \end{aligned} \]

Une fonction de sommet est négative entre les milieux : \( N_0 = -\tfrac19 \) au centre de gravité.

Dérivées de référence

Avec \( \nabla_\xi L_1 = (-1,-1) \), \( \nabla_\xi L_2 = (1,0) \), \( \nabla_\xi L_3 = (0,1) \) : aux sommets \( \nabla_\xi N_i = (4L_i - 1)\,\nabla_\xi L_i \), aux milieux \( \nabla_\xi N = 4(L_b\,\nabla_\xi L_a + L_a\,\nabla_\xi L_b) \). Par exemple :

\[ \nabla_\xi N_0 = \big(-(4L_1-1),\ -(4L_1-1)\big), \qquad \nabla_\xi N_3 = \big(4(L_1 - L_2),\ -4L_2\big). \]

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT6
élasticité plane ou axisymétriqueu_x, u_y12
échanges, rayonnement, sur la face d’un volume quadratiqueT6

Quadrature

"GAUSS" : règle symétrique de Dunavant à 6 points, en deux familles de trois, exacte au degré 4. Avec \( b_1 \simeq 0{,}445948 \) et \( b_2 \simeq 0{,}091576 \), chaque famille réunit les points de coordonnées barycentriques \( (b, b, 1 - 2b) \) et leurs permutations.

famillepoint type \( (\xi, \eta) \)poids
1\( (b_1, b_1) \)\( 0{,}111691 \)
2\( (b_2, b_2) \)\( 0{,}054976 \)

\( \sum_g w_g = \tfrac12 \). La masse, de degré 4, est exacte sur un triangle à côtés droits.

"REDUCED" : un point au centre de gravité \( (\tfrac13, \tfrac13) \), de poids \( \tfrac12 \).

Exemple

import pyrucast

tri6 = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(2), "TRI6"), interpolation="LAGRANGE2"
)[0]
print(tri6.nodes_per_cell, tri6.gauss_count)  # 6 6

# Deux familles de trois points, chacune avec son poids.
print([round(tri6.gauss_weight(g), 6) for g in range(6)])
# [0.111691, 0.111691, 0.111691, 0.054976, 0.054976, 0.054976]
# Au point 0, les fonctions des sommets peuvent être négatives.
print([round(v, 6) for v in tri6.n_at_g(0)])
# [-0.08473, -0.048208, -0.048208, 0.192834, 0.79548, 0.192834]

Côté Rust

Tri6 (src/atoms/element_kind/tri6.rs) implémente ElementKind ; sa règle, tri6_gauss, est partagée avec le PENTA15 (src/atoms/element_kind/quadrature.rs).

QUA8 — quadrangle sérendipité

En bref

Le quadrangle à huit nœuds : les sommets et les milieux des côtés, sans nœud central. Une inconnue de moins par champ que le QUA9, pour un polynôme incomplet : il lui manque le terme \( \xi^2\eta^2 \).

nom"QUA8"
nœuds8 : les sommets, puis les milieux des côtés
interpolation"LAGRANGE2" : sérendipité
dimension2 ; dans des Coords 2-D ou 3-D
bord4 SEG3
quadrature"GAUSS" : 3×3 points, degré 5 par direction ; "REDUCED" : 1 point
parent linéaireQUA4
obtenu parsweep, transfinite, pave_surface, grid_surface ; to_quadratic d’un QUA4 ; skin d’un HEX20 ou PENTA15

Élément de référence

Le carré \( [-1, +1]^2 \) : les sommets 0 à 3, puis les milieux 4 à 7.

Nœud\( (\xi, \eta) \)rôle
0\( (-1, -1) \)sommet
1\( (+1, -1) \)sommet
2\( (+1, +1) \)sommet
3\( (-1, +1) \)sommet
4\( (0, -1) \)milieu de \( (0,1) \)
5\( (+1, 0) \)milieu de \( (1,2) \)
6\( (0, +1) \)milieu de \( (2,3) \)
7\( (-1, 0) \)milieu de \( (3,0) \)

Fonctions de forme

Aux sommets, de coordonnées \( (\xi_i, \eta_i) \) :

\[ N_i = \tfrac14\,(1 + \xi_i\xi)(1 + \eta_i\eta)\,(\xi_i\xi + \eta_i\eta - 1). \]

Aux milieux :

\[ \begin{aligned} N_4 &= \tfrac12(1 - \xi^2)(1 - \eta), & N_5 &= \tfrac12(1 + \xi)(1 - \eta^2), \\ N_6 &= \tfrac12(1 - \xi^2)(1 + \eta), & N_7 &= \tfrac12(1 - \xi)(1 - \eta^2). \end{aligned} \]

Au centre, les sommets valent \( -\tfrac14 \) et les milieux \( \tfrac12 \).

Dérivées de référence

En dérivant ces expressions. Pour le milieu 4 : \( \partial_\xi N_4 = -\xi(1-\eta) \), \( \partial_\eta N_4 = -\tfrac12(1-\xi^2) \).

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT8
élasticité plane ou axisymétriqueu_x, u_y16
échanges, rayonnement, sur la face d’un volume quadratiqueT8

Quadrature

"GAUSS" : produit 3×3 de Gauss-Legendre, 9 points, exact au degré 5 par direction. Le point 4 est le centre, de poids \( \tfrac{64}{81} \) ; \( \sum_g w_g = 4 \). La masse, de degré 4 par direction, est exacte sur un parallélogramme à côtés droits.

"REDUCED" : un point au centre, de poids 4.

Exemple

import pyrucast

qua8 = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(2), "QUA8"), interpolation="LAGRANGE2"
)[0]
print(qua8.nodes_per_cell, qua8.gauss_count)  # 8 9

# Le point 4 est le centre (0, 0) : les sommets y valent −¼, les milieux ½.
print(qua8.gauss_xi(4), round(qua8.gauss_weight(4), 6))  # [0.0, 0.0] 0.790123
print(qua8.n_at_g(4))  # [-0.25, -0.25, -0.25, -0.25, 0.5, 0.5, 0.5, 0.5]

Côté Rust

Qua8 (src/atoms/element_kind/qua8.rs) implémente ElementKind.

QUA9 — quadrangle biquadratique

En bref

Le quadrangle à neuf nœuds : les sommets, les milieux des côtés et le centre. Son interpolation est le produit de deux paraboles, polynôme de degré 2 complet dans chaque direction, terme \( \xi^2\eta^2 \) compris.

nom"QUA9"
nœuds9 : les sommets, les milieux des côtés, le centre
interpolation"LAGRANGE2" : complète
dimension2 ; dans des Coords 2-D ou 3-D
bord4 SEG3
quadrature"GAUSS" : 3×3 points, degré 5 par direction ; "REDUCED" : 1 point
parent linéaireQUA4
obtenu parsweep, transfinite, pave_surface, grid_surface ; skin d’un HEX27

Élément de référence

Le carré \( [-1, +1]^2 \) : les nœuds du QUA8, puis le centre.

Nœud\( (\xi, \eta) \)rôle
0\( (-1, -1) \)sommet
1\( (+1, -1) \)sommet
2\( (+1, +1) \)sommet
3\( (-1, +1) \)sommet
4\( (0, -1) \)milieu de \( (0,1) \)
5\( (+1, 0) \)milieu de \( (1,2) \)
6\( (0, +1) \)milieu de \( (2,3) \)
7\( (-1, 0) \)milieu de \( (3,0) \)
8\( (0, 0) \)centre

Fonctions de forme

Le produit des trois fonctions de Lagrange quadratiques d’un segment, sur les positions \( -1, 0, +1 \) :

\[ \ell_{-}(t) = \tfrac12 t(t-1), \qquad \ell_{0}(t) = 1 - t^2, \qquad \ell_{+}(t) = \tfrac12 t(t+1), \]

et \( N_i(\xi, \eta) = \ell_a(\xi)\,\ell_b(\eta) \), où \( a \) et \( b \) sont les positions du nœud dans chaque direction. Le centre a \( N_8 = (1-\xi^2)(1-\eta^2) \).

Dérivées de référence

\[ \frac{\partial N_i}{\partial \xi} = \ell_a'(\xi)\,\ell_b(\eta), \qquad \frac{\partial N_i}{\partial \eta} = \ell_a(\xi)\,\ell_b'(\eta), \]

avec \( \ell_{-}'(t) = t - \tfrac12 \), \( \ell_0'(t) = -2t \), \( \ell_{+}'(t) = t + \tfrac12 \).

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT9
élasticité plane ou axisymétriqueu_x, u_y18
échanges, rayonnement, sur la face d’un volume quadratiqueT9

Quadrature

"GAUSS" : produit 3×3 de Gauss-Legendre, 9 points, exact au degré 5 par direction ; \( \sum_g w_g = 4 \). La masse, de degré 4 par direction, est exacte sur un parallélogramme à côtés droits.

"REDUCED" : un point au centre, de poids 4.

Exemple

import pyrucast

qua9 = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(2), "QUA9"), interpolation="LAGRANGE2"
)[0]
print(qua9.nodes_per_cell, qua9.gauss_count)  # 9 9

# Au centre, seul le nœud central est non nul : N_8 = (1 − ξ²)(1 − η²) = 1.
n = qua9.n_at_g(4)
print(qua9.gauss_xi(4), [i for i, v in enumerate(n) if abs(v) > 1e-12], n[8])
# [0.0, 0.0] [8] 1.0

Côté Rust

Qua9 (src/atoms/element_kind/qua9.rs) implémente ElementKind.

TET10 — tétraèdre quadratique

En bref

Le tétraèdre à dix nœuds, quatre aux sommets et six aux milieux des arêtes. La déformation y varie linéairement, et les faces peuvent être courbes : il garde la souplesse de maillage du TET4 sans sa raideur en flexion.

nom"TET10"
nœuds10 : les sommets, puis les milieux des arêtes
interpolation"LAGRANGE2" : quadratique complète
dimension3 ; dans des Coords 3-D
bord4 TRI6
quadrature"GAUSS" : 11 points, degré 4 ; "REDUCED" : 1 point
parent linéaireTET4
obtenu parto_quadratic d’un TET4

Élément de référence

Le tétraèdre du TET4, avec ses coordonnées barycentriques \( L_0 = 1-\xi-\eta-\zeta \), \( L_1 = \xi \), \( L_2 = \eta \), \( L_3 = \zeta \).

Nœud\( (\xi, \eta, \zeta) \)Nœudarête\( (\xi, \eta, \zeta) \)
0\( (0,0,0) \)4\( (0,1) \)\( (\tfrac12,0,0) \)
1\( (1,0,0) \)5\( (1,2) \)\( (\tfrac12,\tfrac12,0) \)
2\( (0,1,0) \)6\( (2,0) \)\( (0,\tfrac12,0) \)
3\( (0,0,1) \)7\( (0,3) \)\( (0,0,\tfrac12) \)
8\( (1,3) \)\( (\tfrac12,0,\tfrac12) \)
9\( (2,3) \)\( (0,\tfrac12,\tfrac12) \)

Fonctions de forme

\( L_i(2L_i - 1) \) aux sommets, \( 4 L_a L_b \) aux milieux :

\[ \begin{aligned} N_i &= L_i(2L_i-1) \quad (i = 0 \dots 3), \\ N_4 &= 4 L_0 L_1, \quad N_5 = 4 L_1 L_2, \quad N_6 = 4 L_2 L_0, \\ N_7 &= 4 L_0 L_3, \quad N_8 = 4 L_1 L_3, \quad N_9 = 4 L_2 L_3. \end{aligned} \]

Au centre, où tous les \( L_i \) valent \( \tfrac14 \), les sommets valent \( -\tfrac18 \) et les milieux \( \tfrac14 \).

Dérivées de référence

Aux sommets \( \nabla_\xi N_i = (4L_i - 1)\,\nabla_\xi L_i \), aux milieux \( \nabla_\xi N = 4(L_b\,\nabla_\xi L_a + L_a\,\nabla_\xi L_b) \), avec \( \nabla_\xi L_0 = (-1,-1,-1) \), \( \nabla_\xi L_1 = (1,0,0) \), \( \nabla_\xi L_2 = (0,1,0) \), \( \nabla_\xi L_3 = (0,0,1) \).

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT10
élasticité 3-Du_x, u_y, u_z30

Quadrature

"GAUSS" : règle de Keast à 11 points, exacte au degré 4 :

famillepointspoids
le centre\( (\tfrac14, \tfrac14, \tfrac14) \)\( -\tfrac{74}{5625} \)
vers les sommets\( (\tfrac1{14}, \tfrac1{14}, \tfrac1{14}) \) et les permutations de \( (\tfrac{11}{14}, \tfrac1{14}, \tfrac1{14}) \)\( \tfrac{343}{45000} \)
vers les milieuxles permutations de \( (d, c, c) \) et \( (d, d, c) \), \( d \simeq 0{,}399404 \), \( c \simeq 0{,}100596 \)\( \tfrac{56}{2250} \)

\( \sum_g w_g = \tfrac16 \). Le poids du centre est négatif ; la règle n’en reste pas moins exacte. La masse, de degré 4, est exacte sur un tétraèdre à arêtes droites.

"REDUCED" : un point au centre, de poids \( \tfrac16 \).

Exemple

import pyrucast

tet10 = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(3), "TET10"), interpolation="LAGRANGE2"
)[0]
print(tet10.nodes_per_cell, tet10.gauss_count)  # 10 11

# Le point 0 est le centre, de poids négatif −74/5625.
print(tet10.gauss_xi(0), round(tet10.gauss_weight(0), 6))
# [0.25, 0.25, 0.25] -0.013156
print(round(sum(tet10.gauss_weight(g) for g in range(11)), 12))  # 0.166666666667 = 1/6
# Au centre, L_i = ¼ : les sommets valent −⅛, les milieux ¼.
print(tet10.n_at_g(0))
# [-0.125, -0.125, -0.125, -0.125, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25]

Côté Rust

Tet10 (src/atoms/element_kind/tet10.rs) implémente ElementKind. gauss_tet10_exact_up_to_degree_4 (src/atoms/element_kind/quadrature.rs) intègre tous les monômes de degré au plus 4.

PENTA15 — prisme sérendipité

En bref

Le prisme à quinze nœuds : les six sommets et les milieux des neuf arêtes, sans nœud de face ni de centre. L’interpolation est quadratique dans le triangle et en hauteur. C’est l’extrusion quadratique du TRI6.

nom"PENTA15"
nœuds15 : les sommets, puis les milieux des arêtes
interpolation"LAGRANGE2" : sérendipité
dimension3 ; dans des Coords 3-D
bord2 TRI6, 3 QUA8
quadrature"GAUSS" : 6 × 3 = 18 points ; "REDUCED" : 1 point
parent linéairePENTA6
obtenu parto_quadratic d’un PENTA6

Élément de référence

Le triangle \( \xi, \eta \ge 0 \), \( \xi + \eta \le 1 \), étiré sur \( t = \zeta \in [0, 1] \), avec les coordonnées barycentriques \( L_1 = 1-\xi-\eta \), \( L_2 = \xi \), \( L_3 = \eta \).

Nœud\( (\xi, \eta, \zeta) \)rôle
0\( (0, 0, 0) \)sommet du bas
1\( (1, 0, 0) \)sommet du bas
2\( (0, 1, 0) \)sommet du bas
3\( (0, 0, 1) \)sommet du haut
4\( (1, 0, 1) \)sommet du haut
5\( (0, 1, 1) \)sommet du haut
6\( (\tfrac12, 0, 0) \)milieu de \( (0,1) \)
7\( (\tfrac12, \tfrac12, 0) \)milieu de \( (1,2) \)
8\( (0, \tfrac12, 0) \)milieu de \( (2,0) \)
9\( (\tfrac12, 0, 1) \)milieu de \( (3,4) \)
10\( (\tfrac12, \tfrac12, 1) \)milieu de \( (4,5) \)
11\( (0, \tfrac12, 1) \)milieu de \( (5,3) \)
12\( (0, 0, \tfrac12) \)milieu de \( (0,3) \)
13\( (1, 0, \tfrac12) \)milieu de \( (1,4) \)
14\( (0, 1, \tfrac12) \)milieu de \( (2,5) \)

Fonctions de forme

Avec \( L_1 = 1-\xi-\eta \), \( L_2 = \xi \), \( L_3 = \eta \) et \( t = \zeta \).

Aux sommets, la fonction du TRI6 en hauteur, moins une correction qui s’annule aux deux faces :

\[ \begin{aligned} N^{\text{bas}}_i &= L_i(2L_i-1)(1-t) - 2 L_i\,t(1-t), \\ N^{\text{haut}}_i &= L_i(2L_i-1)\,t - 2 L_i\,t(1-t). \end{aligned} \]

Aux milieux des côtés du triangle, en bas puis en haut :

\[ N = 4 L_a L_b\,(1-t), \qquad N = 4 L_a L_b\,t . \]

Aux milieux des arêtes verticales :

\[ N = 4 L_i\,t(1 - t). \]

Dérivées de référence

En dérivant ces expressions : les dérivées des \( L_i \) sont constantes, et celle en \( t \) vient des facteurs \( (1-t) \), \( t \) et \( t(1-t) \).

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT15
élasticité 3-Du_x, u_y, u_z45

Quadrature

"GAUSS" : le produit de la règle du TRI6 (6 points) et de Gauss-Legendre à 3 points sur \( [0, 1] \), soit 18 points, exact au degré 4 dans le triangle et 5 en hauteur ; \( \sum_g w_g = \tfrac12 \). La masse, de degré 4 dans chaque sens, est exacte sur un prisme à arêtes droites dont le triangle du haut est celui du bas déplacé.

"REDUCED" : un point en \( (\tfrac13, \tfrac13, \tfrac12) \), de poids \( \tfrac12 \).

Exemple

import pyrucast

penta15 = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(3), "PENTA15"), interpolation="LAGRANGE2"
)[0]
print(penta15.nodes_per_cell, penta15.gauss_count)  # 15 18

# Les six points du TRI6, à trois hauteurs : le premier en ζ = ½ − ½√(3/5).
print([round(x, 6) for x in penta15.gauss_xi(0)])  # [0.445948, 0.445948, 0.112702]
print(round(sum(penta15.gauss_weight(g) for g in range(18)), 12))  # 0.5

Côté Rust

Penta15 (src/atoms/element_kind/penta15.rs) implémente ElementKind, avec la règle tri6_gauss du TRI6 et gauss3_1d, partagées dans src/atoms/element_kind/quadrature.rs.

HEX20 — hexaèdre sérendipité

En bref

L’hexaèdre à vingt nœuds : les huit sommets et les milieux des douze arêtes, sans nœud de face ni de centre. Sept nœuds de moins que le HEX27, pour un polynôme incomplet, sans les termes comme \( \xi^2\eta^2\zeta^2 \).

nom"HEX20"
nœuds20 : les sommets, puis les milieux des arêtes
interpolation"LAGRANGE2" : sérendipité
dimension3 ; dans des Coords 3-D
bord6 QUA8
quadrature"GAUSS" : 3×3×3 points, degré 5 par direction ; "REDUCED" : 1 point
parent linéaireHEX8
obtenu parto_quadratic d’un HEX8

Élément de référence

Le cube \( [-1, +1]^3 \). Un milieu d’arête a exactement une coordonnée nulle.

Nœud\( (\xi, \eta, \zeta) \)rôle
0\( (-1,-1,-1) \)sommet
1\( (+1,-1,-1) \)sommet
2\( (+1,+1,-1) \)sommet
3\( (-1,+1,-1) \)sommet
4\( (-1,-1,+1) \)sommet
5\( (+1,-1,+1) \)sommet
6\( (+1,+1,+1) \)sommet
7\( (-1,+1,+1) \)sommet
8\( (0,-1,-1) \)milieu de \( (0,1) \)
9\( (+1,0,-1) \)milieu de \( (1,2) \)
10\( (0,+1,-1) \)milieu de \( (2,3) \)
11\( (-1,0,-1) \)milieu de \( (3,0) \)
12\( (0,-1,+1) \)milieu de \( (4,5) \)
13\( (+1,0,+1) \)milieu de \( (5,6) \)
14\( (0,+1,+1) \)milieu de \( (6,7) \)
15\( (-1,0,+1) \)milieu de \( (7,4) \)
16\( (-1,-1,0) \)milieu de \( (0,4) \)
17\( (+1,-1,0) \)milieu de \( (1,5) \)
18\( (+1,+1,0) \)milieu de \( (2,6) \)
19\( (-1,+1,0) \)milieu de \( (3,7) \)

Fonctions de forme

Pour un nœud de coordonnées \( (p, q, r) \).

Aux sommets (\( p, q, r = \pm1 \)), noter le \( -2 \) :

\[ N_i = \tfrac18\,(1 + p\xi)(1 + q\eta)(1 + r\zeta)\,(p\xi + q\eta + r\zeta - 2). \]

Aux milieux, selon la coordonnée nulle :

\[ \begin{aligned} p = 0 :&\quad N = \tfrac14(1 - \xi^2)(1 + q\eta)(1 + r\zeta), \\ q = 0 :&\quad N = \tfrac14(1 + p\xi)(1 - \eta^2)(1 + r\zeta), \\ r = 0 :&\quad N = \tfrac14(1 + p\xi)(1 + q\eta)(1 - \zeta^2). \end{aligned} \]

Au centre, les sommets valent \( -\tfrac14 \) et les milieux \( \tfrac14 \).

Dérivées de référence

En dérivant ces expressions.

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT20
élasticité 3-Du_x, u_y, u_z60

Quadrature

"GAUSS" : produit 3×3×3 de Gauss-Legendre, 27 points, exact au degré 5 par direction ; le point 13 est le centre ; \( \sum_g w_g = 8 \). La masse, de degré 4 par direction, est exacte sur un parallélépipède à arêtes droites.

"REDUCED" : un point au centre, de poids 8.

Exemple

import pyrucast

hex20 = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(3), "HEX20"), interpolation="LAGRANGE2"
)[0]
print(hex20.nodes_per_cell, hex20.gauss_count)  # 20 27

# Le point 13 est le centre : les sommets y valent −¼, les milieux ¼.
n = hex20.n_at_g(13)
print(hex20.gauss_xi(13), n[0], n[8])  # [0.0, 0.0, 0.0] -0.25 0.25
print(sum(n))  # 1.0

Côté Rust

Hex20 (src/atoms/element_kind/hex20.rs) implémente ElementKind.

HEX27 — hexaèdre triquadratique

En bref

L’hexaèdre à vingt-sept nœuds : sommets, milieux des arêtes, centres des faces et centre. Son interpolation est le produit de trois paraboles, polynôme de degré 2 complet dans chaque direction. Aucun mailleur de pyrucast ne le produit : il vient d’un fichier gmsh ou de from_arrays.

nom"HEX27"
nœuds27 : sommets, milieux des arêtes, centres des faces, centre
interpolation"LAGRANGE2" : complète
dimension3 ; dans des Coords 3-D
bord6 QUA9
quadrature"GAUSS" : 3×3×3 points, degré 5 par direction ; "REDUCED" : 1 point
parent linéaireHEX8
obtenu parlecture : read_gmsh, from_arrays

Élément de référence

Le cube \( [-1, +1]^3 \).

Nœud\( (\xi, \eta, \zeta) \)rôle
0\( (-1,-1,-1) \)sommet
1\( (+1,-1,-1) \)sommet
2\( (+1,+1,-1) \)sommet
3\( (-1,+1,-1) \)sommet
4\( (-1,-1,+1) \)sommet
5\( (+1,-1,+1) \)sommet
6\( (+1,+1,+1) \)sommet
7\( (-1,+1,+1) \)sommet
8\( (0,-1,-1) \)milieu de \( (0,1) \)
9\( (+1,0,-1) \)milieu de \( (1,2) \)
10\( (0,+1,-1) \)milieu de \( (2,3) \)
11\( (-1,0,-1) \)milieu de \( (3,0) \)
12\( (0,-1,+1) \)milieu de \( (4,5) \)
13\( (+1,0,+1) \)milieu de \( (5,6) \)
14\( (0,+1,+1) \)milieu de \( (6,7) \)
15\( (-1,0,+1) \)milieu de \( (7,4) \)
16\( (-1,-1,0) \)milieu de \( (0,4) \)
17\( (+1,-1,0) \)milieu de \( (1,5) \)
18\( (+1,+1,0) \)milieu de \( (2,6) \)
19\( (-1,+1,0) \)milieu de \( (3,7) \)
20\( (-1,0,0) \)centre de la face \( \xi = -1 \)
21\( (+1,0,0) \)centre de la face \( \xi = +1 \)
22\( (0,-1,0) \)centre de la face \( \eta = -1 \)
23\( (0,+1,0) \)centre de la face \( \eta = +1 \)
24\( (0,0,-1) \)centre de la face \( \zeta = -1 \)
25\( (0,0,+1) \)centre de la face \( \zeta = +1 \)
26\( (0,0,0) \)centre

Fonctions de forme

Le produit des trois fonctions de Lagrange quadratiques d’un segment, sur les positions \( -1, 0, +1 \) :

\[ \ell_{-}(t) = \tfrac12 t(t-1), \qquad \ell_{0}(t) = 1 - t^2, \qquad \ell_{+}(t) = \tfrac12 t(t+1), \]

et \( N_i(\xi, \eta, \zeta) = \ell_a(\xi)\,\ell_b(\eta)\,\ell_c(\zeta) \), où \( a, b, c \) sont les positions du nœud dans chaque direction. Le centre a \( N_{26} = (1-\xi^2)(1-\eta^2)(1-\zeta^2) \).

Dérivées de référence

\[ \frac{\partial N_i}{\partial \xi} = \ell_a'(\xi)\,\ell_b(\eta)\,\ell_c(\zeta), \]

et de même en \( \eta \) et \( \zeta \), avec \( \ell_{-}'(t) = t - \tfrac12 \), \( \ell_0'(t) = -2t \), \( \ell_{+}'(t) = t + \tfrac12 \).

Degrés de liberté

physiqueinconnues par nœudpar maille
conduction, diffusionT27
élasticité 3-Du_x, u_y, u_z81

Quadrature

"GAUSS" : produit 3×3×3 de Gauss-Legendre, 27 points, exact au degré 5 par direction ; \( \sum_g w_g = 8 \). La masse, de degré 4 par direction, est exacte sur un parallélépipède à arêtes droites.

"REDUCED" : un point au centre, de poids 8.

Exemple

import pyrucast

hex27 = pyrucast.FiniteElementSpace(
    pyrucast.Mesh(pyrucast.Coords(3), "HEX27"), interpolation="LAGRANGE2"
)[0]
print(hex27.nodes_per_cell, hex27.gauss_count)  # 27 27

# Au centre, seul le nœud 26, le centre de volume, vaut 1.
n = hex27.n_at_g(13)
non_nuls = [i for i, v in enumerate(n) if abs(v) > 1e-12]
print(hex27.gauss_xi(13), non_nuls)  # [0.0, 0.0, 0.0] [26]

Côté Rust

Hex27 (src/atoms/element_kind/hex27.rs) implémente ElementKind.

Détail des opérateurs

Les opérateurs sont les fonctions libres de pyrucast : elles croisent des conteneurs (maillage + champ, espace EF + champ…) ou appartiennent à une famille d’opérateurs, par opposition aux méthodes qui restent sur un seul conteneur (cf. Conventions). Côté Rust elles vivent sous src/ops/<module>, où le module porte le nom du conteneur produit ; côté Python elles sont exposées dans le sous-module de même nom (pyrucast.node_field.positions, pyrucast.matrix.stiffness, …).

Les chapitres qui suivent sont organisés par sujet, ce qui ne recoupe pas toujours le module d’implémentation — la colonne de gauche donne la correspondance.

Module RustChapitreContenu
ops::meshMaillageline, circle, arc, extrude, revolve, sweep, transfinite (DALL), sweep_solid, copy, translate, rotate, symmetry_point, symmetry_line, symmetry_plane, project_on_sphere, triangulate_surface, pave_surface, grid_surface, grid_surface2, triangulate_volume, pave_volume, regularize, cleanup, merge_triangles, border, skin, orient, invert, chain, elements_on, sélection de nœuds par région (points_in_sphere, points_on_plane, points_in_cylinder, points_on_cone, points_on_torus…), merge_nodes, read_gmsh, from_gmsh, from_medcoupling, from_arrays, poi1_from_nodes, from_live_nodes, to_poi1, to_quadratic, convert, barycenter, mesh.consolidate…
ops::modelModèleles déclarations de physique : heat_conduction, fick, radiation, boundary_transfer, interface_transfer, elasticity, plasticity_perfect et les lois d’écoulement, mazars et les lois d’endommagement, truss, bernoulli, timoshenko, shell, et les contraintes dirichlet, mpc, embedded, contact
ops::element_fieldConstructionchamps matériau (material_field…)
ops::coordsChampsset, displace, displace_from (configuration X + u posée en absolu, FORM) — les trois seuls opérateurs qui écrivent la géométrie ; in_config (calculer le temps d’un bloc dans une autre configuration)
ops::evolutionÉvolutionalong (un champ relevé le long d’une ligne de points, une courbe par composante)
ops::measureChampsintegral / integral_element (∫ f dΩ), xtx / xty (produits scalaires globaux)
ops::geomGéométrielocate_points (mapping inverse, baignage), project_points (projection sur surface, contact) — internes, pas exposées à Python
ops::node_field, ops::element_field, ops::fieldChampspositions, gradient, divergence, deformation (ε, F, Green–Lagrange, Cauchy-Green, Hencky, Almansi, J = det F), beam_deformation, shell_deformation, interp_to_gauss (nœuds → Gauss), strain (le B du modèle : l’entrée de chaque loi, EPSI), thermal_strain (déformation thermique EPTH), elastic_stress / elastic_strain (opérateur élastique ELAS, D:ε et D⁻¹:σ), von_mises (contrainte équivalente, von Mises ou Hill, VMIS), stress_measure (Cauchy, Kirchhoff, Piola–Kirchhoff 1 et 2 l’une vers l’autre, CONF pour les contraintes), rotate_frame (axes matériau tournés avec la pièce, R de F = R U), cell_integral (intégrale par maille, INTG 'ELEM'), sum_to_nodes (somme aux nœuds d’un champ par maille, CHAN 'CHPO' 'SOMM'), restrict, restrict_like (reprojection sur le support d’un champ cible), project (interpolation aux nœuds d’un autre maillage, PROI ; d’un champ aux points de Gauss par l’ajustement de chaque maille), select, mask, filter_components / rename_component (extraction et renommage de composantes, EXCO), merge, node_field.consolidate / element_field.consolidate, integral / integral_element (intégrale ∫ f dΩ), xty / xtx (produits scalaires globaux) / psca (produit scalaire nœud par nœud), maths élément par élément (abs, sqrt, exp, cos…)…
ops::matrixAssemblagestiffness, mass, rigidité géométrique geometric, tangente cohérente tangent, concentration lump, composition assemble (assemble des blocs réunis sans leurs valeurs, sans Model), et les deux côtés du bilan Σ f_int = Σ f_ext — internal_forces (le BSIG, ∫ Bᵀ σ ; sans modèle c’est node_field.divergence(field, "sigma")) et external_forces, d’où sort tout terme donné : l’ambiant d’un transfert de bord, la densité d’une charge répartie
ops::element_field::behaviorComportementinitial_state (l’état de repos, l’état de A du premier pas), integrate_behavior (le COMP)
ops::solverSolveursolve : multiplicateurs (LU creux), élimination (relations="eliminate"), ensemble actif dès qu’une relation est unilatérale
ops::exportVisualisationexport_vtk (maillage / champ / évolution → VTK ASCII ou binaire pour ParaView), to_arrays, to_gmsh, to_medcoupling
archiveSauvegarde et relecturesave / load (graphe d’objets, partage préservé) — au niveau racine, hors ops
src/vizVisualisationtracé des maillages, coloration par champ

Le découpage est par conteneur produit : gradient(field, fespace) rend un ElementField, il vit donc dans ops::element_field à côté de deformation, et non avec divergence (qui rend un champ nodal). Une opération se range par sa sortie, jamais par son entrée. Le binding Python reste un miroir 1:1 — voir Correspondance Rust ↔ Python.

La fonction libre est la forme canonique : c’est elle qui est documentée dans les chapitres qui suivent. La plupart de ces opérations sont aussi des méthodes de leur sujet, pour permettre le chaînage — maillage.border().consolidate() plutôt que mesh.consolidate(mesh.border(maillage)). La règle qui décide lesquelles, et ses exclusions, tient dans les trois conditions de Conventions.

La chaîne typique d’un calcul enchaîne ces opérateurs :

mesh ──► (Mesh) ──► FiniteElementSpace
                                 │
element_field ── material_field ─┤
                                 ▼
matrix ── stiffness ──────────► (Matrix) ──┐
node_field ── flux/positions ──► (RHS) ──┤
                                           ▼
                          solver ── solve ──► (NodeField solution)
                                           │
   element_field ── strain ──► element_field.behavior ── integrate ──► (efforts)
                                     │
                                     ▼
                                   viz ── plot

Opérateurs de maillage

Les mailleurs (ops::mesh) construisent et transforment des maillages. Chacun prend ses conteneurs par référence et renvoie un nouveau Mesh. Côté Python ils sont exposés à plat (pyrucast.mesh.line, …).

Inventaire

PythonRôle
from_live_nodes(coords)un Mesh POI1 de tous les nœuds vivants d’un Coords
poi1_from_nodes(nodes)un Mesh POI1 sur une liste de nœuds donnée
line(a, b, n_elems, element_type="SEG2")une ligne de n_elems éléments (SEG2 ou SEG3) entre deux nœuds (nœuds intermédiaires créés)
circle(center, normal, radius, n_elems, element_type="SEG2")un cercle fermé (SEG2 ou SEG3, plan défini par normal) ; en SEG3, le nœud milieu est sur le cercle
arc(a, center, b, n_elems, element_type="SEG2")un arc de a à b sur le cercle de centre center passant par les deux (le plus court des deux arcs) ; en SEG3, le nœud milieu est sur l’arc
extrude(mesh, direction, n_layers)extrude un maillage le long de direction (SEG2→QUA4, TRI3→PENTA6, QUA4→HEX8, et en quadratique SEG3→QUA8, TRI6→PENTA15, QUA8→HEX20)
revolve(mesh, angle, n_layers, center, axis=None)compagnon rotatif d’extrude : balaye un maillage de angle (rad) autour de center (axe axis en 3D), mêmes types qu’extrude ; un tour complet referme l’anneau ; une ligne qui touche l’axe s’y referme en triangles (TRI3, TRI6) (voir plus bas)
sweep(mesh_a, mesh_b, n_layers, element_type=None)tisse une surface entre deux lignes : QUA4 (défaut), TRI3, QUA8, QUA9, TRI6 entre deux SEG2 ; QUA8 (défaut) ou QUA9 entre deux SEG3 ; deux boucles fermées donnent un anneau fermé (voir plus bas)
transfinite(side1, side2, side3, side4, element_type="QUA4")généralisation de sweep à 4 côtés (l’équivalent Cast3M de DALL) : interpolation transfinie (patch de Coons) entre quatre lignes SEG2 formant un contour fermé (voir plus bas)
sweep_solid(mesh_a, mesh_b, n_layers)compagnon 3D de sweep : tisse un solide entre deux surfaces (TRI3→PENTA6, QUA4→HEX8)
copy(mesh, new_nodes=True)copie du maillage sur des nœuds neufs aux mêmes endroits, ou (new_nodes=False) sur les mêmes nœuds — dans les deux cas descellée (voir plus bas)
translate(mesh, vector)copie du maillage translatée de vector (nœuds neufs, original intact)
rotate(mesh, angle, center, axis=None)copie du maillage tournée de angle (rad) autour de center (axe axis en 3D)
symmetry_point(mesh, center)copie symétrique par rapport au point center (Cast3M SYME, voir plus bas)
symmetry_line(mesh, a, b)copie symétrique par rapport à la droite passant par a et b (demi-tour en 3D)
symmetry_plane(mesh, a, b, c)copie symétrique par rapport au plan passant par trois points (3D)
project_on_sphere(mesh, center, radius)copie projetée radialement sur la sphère (le cercle en 2D) : chaque nœud poussé le long du rayon issu de center (voir plus bas)
triangulate_surface(contour, type, size=None)maille l’intérieur de contours orientés (CCW extérieur, CW trous) par Delaunay contraint + raffinement Ruppert (voir plus bas)
pave_surface(contour, type, size=None, prefer_quads=False, relax="free")pave l’intérieur de contours orientés (CCW extérieur, CW trous) en QUA4/QUA8/QUA9, par front avançant en rangées parallèles au bord (voir plus bas)
grid_surface(contour, type, size=None, band=0, prefer_quads=False, relax="free")maille l’intérieur de contours orientés (CCW extérieur, CW trous) par cœur en grille cartésienne et bande frontale au bord : sur une forme rectilinéaire, la grille régulière que le front ne sait pas produire (voir plus bas)
grid_surface2(contour, type, size=None, band=0, prefer_quads=False, relax="free")maille l’intérieur de contours orientés par cœur en grille et bande frontale au bord, les lignes venant une par nœud du contour et les rangées ayant le droit de plier : meilleur sur les formes rectilinéaires mal découpées, moins bon sur les courbes (voir plus bas)
pave_volume(envelope, layers=1, thickness=None, size=None)compagnon 3D de pave_surface : couche limite d’HEX8/PENTA6 poussée vers l’intérieur, raccordée par des PYRA5 à un cœur TET4 (voir plus bas)
triangulate_volume(envelope, size=None, allow_surface_nodes=False)compagnon 3D de triangulate_surface : maille l’intérieur d’une enveloppe TRI3 fermée en TET4 — Delaunay exact, récupération du bord, raffinement intérieur et chasse aux slivers (voir plus bas)
regularize(mesh, sweeps=20, angular=True, in_place=False)lisse un maillage de surface existant : déplace ses nœuds intérieurs pour améliorer ses mailles, sans toucher ni à la connectivité ni au bord (voir plus bas)
cleanup(mesh)corrige la connectivité d’un maillage de surface : doublets, valences fautives, effondrement de l’étoile d’un nœud intérieur qui n’a que trois mailles, et de la paire de nœuds voisins qui en manquent (voir plus bas)
merge_triangles(mesh)retire les triangles d’un maillage à dominante quadrangulaire, par paires — leur nombre a la parité du bord (voir plus bas)
border(mesh, angle_deg=None)le bord d’un maillage de surface en boucles SEG2 (TRI3/QUA4) ou SEG3 (TRI6/QUA8/QUA9), une par sous-maillage ; avec angle_deg, découpé en arêtes ouvertes aux coins (voir plus bas)
skin(mesh, angle_deg=None)la peau d’un maillage volumique (tout type, y compris PYRA5 et les quadratiques) en faces de même degré, une par face plane du solide (voir plus bas)
orient(mesh)harmonise l’orientation des cellules (normales cohérentes), toute dimension (SEG/TRI/QUA/TET/PENTA/HEX), équivalent Cast3M ORIE (voir plus bas)
invert(mesh)inverse l’orientation de toutes les cellules, toute dimension, équivalent Cast3M INVE (voir plus bas)
chain(mesh)réordonne les mailles d’une ligne (SEG2/SEG3) en chaîne continue — le complément d’orient, qui corrige le sens mais pas l’ordre (voir plus bas)
elements_on(mesh, points, strict=True)les éléments de mesh qui s’appuient sur les nœuds de points (voir plus bas)
points_in_sphere(mesh, center, radius, tol=None)les nœuds dans la sphère (le disque en 2D) — famille points_*, voir plus bas
points_on_sphere(mesh, center, radius, tol=None)les nœuds sur la sphère (le cercle en 2D)
points_on_plane(mesh, origin, normal, tol=None)les nœuds dans le plan (la droite en 2D) — la façon usuelle d’attraper une face de bord
points_below_plane(mesh, origin, normal, tol=None)les nœuds du demi-espace opposé à la normale, plan compris (normale retournée ⇒ l’autre moitié)
points_on_line(mesh, a, b, tol=None)les nœuds sur la droite (infinie) passant par a et b
points_in_cylinder(mesh, base, top, radius, tol=None)les nœuds dans le cylindre fini d’axe base → top
points_on_cylinder(mesh, base, top, radius, tol=None)les nœuds sur la surface latérale du même cylindre (disques d’extrémité exclus)
points_in_cone(mesh, base, top, base_radius, top_radius=0.0, tol=None)les nœuds dans le cône tronqué (top_radius=0 ⇒ cône vrai de sommet top)
points_on_cone(mesh, base, top, base_radius, top_radius=0.0, tol=None)les nœuds sur la surface latérale du même cône
points_in_torus(mesh, center, axis, major_radius, minor_radius, tol=None)les nœuds dans le tore à section circulaire (3D seulement)
points_on_torus(mesh, center, axis, major_radius, minor_radius, tol=None)les nœuds sur la surface du même tore
to_poi1(mesh)les nœuds distincts d’un maillage, en POI1 ; nuage canonique mémoïsé par sous-maillage (le nuage est scellé, le sous-maillage source non ; toute modification de celui-ci lâche le cache) ⇒ handle reproductible, partagé par restrict/blocs de matrice/divergence/flux (supports appariables)
to_quadratic(mesh)la copie quadratique (Lagrange-2) d’un maillage linéaire : TRI3→TRI6, HEX8→HEX20, … (voir plus bas)
convert(mesh, element_type)change le type d’élément sans déplacer ni ajouter de nœud : identité, QUA4→TRI3 (2 triangles), HEX8→TET4 (6 tétraèdres) (voir plus bas)
barycenter(mesh)un POI1 au centre de gravité de chaque cellule, structure de sous-maillage préservée
mesh.consolidate(mesh)fusionne les sous-maillages de même type, en écartant les mailles dupliquées
merge_nodes(mesh, tol, in_place=False)soude les nœuds distants de moins de tol ; remappe la connectivité, abandonne les cellules dégénérées — ou réécrit les sous-maillages sur place avec in_place=True (voir plus bas)
read_gmsh(coords, path)lit un maillage gmsh .msh (2.2 ou 4.1, ASCII ou binaire) dans coords, renvoie un dict {groupe physique: Mesh} (voir plus bas)
read_gmsh_str(coords, text)comme read_gmsh mais depuis le texte du fichier déjà en mémoire
from_gmsh(coords, *, dim=-1, tag=-1, views=True)lit le modèle gmsh vivant et ses vues, sans fichier : (maillages, champs) (voir plus bas)
from_medcoupling(coords, source, *, mesh_name=None)lit un fichier MED (Salome, code_aster) et ses champs au travers de medcoupling : (maillages, champs) (voir plus bas)
from_arrays(coords, node_tags, node_coords, blocks, *, node_fields=(), cell_fields=(), order="pyrucast")l’import générique par tableaux sur lequel reposent les deux précédents (voir plus bas)
element_type_from_gmsh(code)le type pyrucast d’un code d’élément gmsh
gauss_to_external(element_type, ref_nodes, order) / match_gauss(…)la règle de Gauss de pyrucast dans l’élément de référence d’un autre format, et l’appariement inverse

barycenter donne un nœud neuf au centre de chaque cellule, même quand deux cellules partagent leurs nœuds. Les contraintes s’en servent pour créer leurs nœuds-multiplicateurs, un par relation (cf. Dirichlet).

c = pyrucast.Coords(dim=2)
p, q = c.add_node([0.0, 0.0]), c.add_node([4.0, 0.0])

# Two SEG2 sharing their middle node: one new node at the centre of each.
centres = pyrucast.mesh.barycenter(pyrucast.mesh.line(p, q, 2))
print(centres.node(0, 0, 0).position(), centres.node(0, 1, 0).position())
# [1.0, 0.0] [3.0, 0.0]

# On a POI1, a new node on each point: how a constraint mints its multipliers.
multiplicateurs = pyrucast.mesh.barycenter(pyrucast.mesh.poi1_from_nodes([p, q]))
print(multiplicateurs.node(0, 0, 0).position())  # [0.0, 0.0], a node other than p
import pyrucast

c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([4.0, 0.0])

# A line of 4 SEG2 between a and b (3 intermediate nodes created).
line = pyrucast.mesh.line(a, b, 4)
print(line)  # Mesh: 1 submesh(es), 4 cell(s) total

# Extrusion into QUA4 over 2 layers along +y.
surf = pyrucast.mesh.extrude(line, [0.0, 1.0], 2)
print(surf.element_types())  # ['QUA4']

# Quadratic line: SEG3 (one mid-edge node per element).
line3 = pyrucast.mesh.line(a, b, 4, "SEG3")
print(line3.element_types())  # ['SEG3']

En quadratique, le maillage garde son degré. Une ligne SEG3 s’extrude en QUA8, une surface QUA8 en HEX20, une TRI6 en PENTA15. Les nœuds milieux de la source sont recopiés à chaque couche, et chaque arête dans le sens de l’extrusion reçoit le sien à mi-hauteur. Le bord d’une surface quadratique est fait de SEG3, et l’espace éléments finis d’un maillage quadratique prend de lui-même l’interpolation LAGRANGE2 (cf. Espace éléments finis) :

c = pyrucast.Coords(3)
a, b = c.add_node([0.0, 0.0, 0.0]), c.add_node([1.0, 0.0, 0.0])
ligne3 = pyrucast.mesh.line(a, b, 2, "SEG3")
plaque8 = pyrucast.mesh.extrude(ligne3, [0.0, 1.0, 0.0], 2)  # SEG3 → QUA8
bloc20 = pyrucast.mesh.extrude(plaque8, [0.0, 0.0, 0.5], 1)  # QUA8 → HEX20
bord3 = pyrucast.mesh.border(plaque8)  # the border of a QUA8 surface: SEG3
print(plaque8.element_types(), bloc20.element_types(), bord3.element_types())
# ['QUA8'] ['HEX20'] ['SEG3']

sweep : une surface entre deux lignes

sweep(mesh_a, mesh_b, n_layers, element_type=None) tisse n_layers couches entre deux lignes de même degré et de même nombre d’éléments : le nœud j de la première est relié au nœud j de la seconde, et les couches intermédiaires sont interpolées entre eux.

Entre deux lignes SEG2, un maillage QUA4 est toujours construit en premier (le cœur géométrique du tissage) ; si element_type demande autre chose, il est ensuite converti :

  • "TRI3" — chaque QUA4 est coupé en deux TRI3 le long de la diagonale (0, 2) (pas de nœud créé) ;
  • "QUA8" — promotion quadratique via to_quadratic (nœuds de milieu d’arête) ;
  • "QUA9" — comme QUA8, puis un nœud central neuf est ajouté par cellule (moyenne des 4 coins) — to_quadratic ne produit que le QUA8 sérendipité, sans nœud central ;
  • "TRI6" — coupe en TRI3 puis promotion quadratique (mêmes deux étapes composées).
tri = pyrucast.mesh.sweep(mesh_a, mesh_b, 2, "TRI3")  # 2× more cells than QUA4
qua8 = pyrucast.mesh.sweep(mesh_a, mesh_b, 2, "QUA8")
qua9 = pyrucast.mesh.sweep(mesh_a, mesh_b, 2, "QUA9")
tri6 = pyrucast.mesh.sweep(mesh_a, mesh_b, 2, "TRI6")

Entre deux lignes SEG3, la surface est construite directement en QUA8 (le défaut) ou en QUA9 : les nœuds milieux des deux lignes sont repris, si bien qu’une couronne entre deux arcs suit exactement leurs courbes. Les nœuds milieux des arêtes qui vont d’une ligne à l’autre sont interpolés à mi-couche.

Deux boucles fermées donnent un anneau fermé. Quand chaque ligne se termine sur son propre premier nœud — un cercle, un contour fermé —, la dernière colonne de chaque couche est la première : la couture entre les deux boucles est partagée, pas doublée. Une boucle fermée balayée vers une ligne ouverte est une erreur.

# Two concentric circles of 8 segments, 2 layers between them.
dedans = pyrucast.mesh.circle(centre, [0.0, 0.0, 1.0], 1.0, 8)
dehors = pyrucast.mesh.circle(centre, [0.0, 0.0, 1.0], 2.0, 8)
anneau = pyrucast.mesh.sweep(dedans, dehors, 2)
print(anneau.cell_count, pyrucast.mesh.to_poi1(anneau).cell_count)  # 16 24

# The same ring in SEG3: QUA8, the mid-nodes of the circles reused.
dedans3 = pyrucast.mesh.circle(centre, [0.0, 0.0, 1.0], 1.0, 8, "SEG3")
dehors3 = pyrucast.mesh.circle(centre, [0.0, 0.0, 1.0], 2.0, 8, "SEG3")
anneau3 = pyrucast.mesh.sweep(dedans3, dehors3, 2)
print(anneau3.element_types())  # ['QUA8']

Surface entre 4 côtés : transfinite

transfinite(side1, side2, side3, side4, element_type="QUA4") maille une surface structurée délimitée par quatre lignes SEG2 — l’équivalent Cast3M de l’opérateur DALL(er), généralisant sweep (2 lignes) à 4 lignes. side1/side3 et side2/side4 sont les deux paires de côtés opposés ; chaque paire doit avoir le même nombre d’éléments. Les quatre côtés doivent former un contour fermé, orienté de façon cohérente :

side4 dernier nœud == side1 premier nœud
side1 dernier nœud == side2 premier nœud
side2 dernier nœud == side3 premier nœud
side3 dernier nœud == side4 premier nœud

Les nœuds des quatre côtés (coins compris) sont réutilisés ; seuls les nœuds intérieurs sont créés, par interpolation transfinie discrète (patch de Coons bilinéaire) : mélange des deux côtés opposés à chaque direction, corrigé par les quatre coins pour reproduire les côtés exactement sur le bord, quelle que soit leur forme (pas seulement des droites). Comme sweep, un QUA4 est toujours construit d’abord, puis converti pour "TRI3"/"QUA8"/"QUA9"/"TRI6".

c = pyrucast.Coords(dim=2)
p0 = c.add_node([0.0, 0.0])
p1 = c.add_node([2.0, 0.0])
p2 = c.add_node([2.0, 1.0])
p3 = c.add_node([0.0, 1.0])

side1 = pyrucast.mesh.line(p0, p1, 4)  # bottom, 4 elements
side2 = pyrucast.mesh.line(p1, p2, 2)  # right,  2 elements
side3 = pyrucast.mesh.line(p2, p3, 4)  # top,    4 elements (= side1)
side4 = pyrucast.mesh.line(p3, p0, 2)  # left,   2 elements (= side2)

surf = pyrucast.mesh.transfinite(side1, side2, side3, side4)
print(surf.element_types(), surf.cell_count)  # ['QUA4'] 8

Différence avec Cast3M. DALL accepte des côtés opposés avec un nombre de points différent (algorithme de pavage plus général, documenté mais non détaillé dans la notice officielle). transfinite se limite au cas standard de l’interpolation transfinie — côtés opposés de même nombre d’éléments — largement suffisant en pratique et implémentable simplement.

Copie sur place : copy

copy(mesh, new_nodes=True) rend une copie du maillage sans le déplacer : mêmes sous-maillages dans le même ordre, mêmes types, mêmes couleurs, même connectivité. Ce que l’argument tranche, c’est si les deux maillages se tiennent encore par leur géométrie :

  • new_nodes=True (par défaut) crée un nœud neuf par nœud distinct, à la même position, dans la même Coords — un nœud partagé entre plusieurs cellules de la source reste partagé dans la copie. Les deux maillages sont alors indépendants : déplacer un nœud de l’un laisse l’autre en place. C’est ce que font les copies rigides, moins le déplacement.
  • new_nodes=False copie la seule connectivité : ce sont les mêmes nœuds, dont le refcount monte. Déplacer un nœud les déplace tous les deux.

Dans les deux cas la copie est descellée (Maillage), donc de nouveau modifiable même si l’original a été consommé par un espace éléments finis, un champ ou une matrice ; c’est la forme fonction libre de la méthode duplicate(), qui est le cas new_nodes=False.

# A copy on its **own** nodes, at the same places: the two meshes no longer
# move together.
jumelle = pyrucast.mesh.copy(face, new_nodes=True)

# A tracing: same connectivity, **same** nodes. It is unsealed, hence editable
# again even if `face` has already been used in a computation.
calque = face.copy(new_nodes=False)

Copies rigides : translate, rotate et les symétries

translate(mesh, vector), rotate(mesh, angle, center, axis=None) et les trois symétries renvoient une copie neuve du maillage — mêmes sous-maillages, mêmes types, mêmes couleurs, même connectivité — dont tous les nœuds sont nouveaux. Le maillage d’origine (et ses nœuds) reste intact ; un nœud partagé entre plusieurs cellules de la source reste partagé dans la copie.

  • translate décale chaque nœud de vector (dont la longueur doit valoir la dimension du maillage).
  • rotate tourne de angle radians autour de center. En 2D, center est un point et axis est ignoré ; en 3D, la rotation se fait autour de la droite passant par center dirigée par axis (formule de Rodrigues, main droite), et axis est obligatoire (il n’a pas besoin d’être normé).
  • symmetry_point(mesh, center) envoie chaque nœud sur 2·center − x : center est le milieu de chaque nœud et de son image. C’est le demi-tour autour du point en 2D, l’inversion centrale en 3D.
  • symmetry_line(mesh, a, b) réfléchit à travers la droite (infinie) passant par a et b : la composante le long de la droite est gardée, la perpendiculaire est retournée. En 2D c’est l’image miroir dans la droite ; en 3D c’est le demi-tour autour d’elle (une rotation de π) — pour l’image dans un miroir, c’est symmetry_plane qu’il faut.
  • symmetry_plane(mesh, a, b, c) réfléchit à travers le plan passant par les trois points a, b et c : x ↦ x − 2((x − a)·n̂) n̂, où n̂ est la normale unitaire du plan. Les trois points jouent des rôles symétriques — seul compte le plan qu’ils engendrent, pas leur ordre (une permutation retourne n̂, ce à quoi la formule est insensible). 3D uniquement : en 2D, le miroir est symmetry_line, qui prend les deux points de la droite. Erreur si les trois points sont alignés (ils n’engendrent alors aucun plan).

Orientation des cellules

Une symétrie peut retourner l’orientation : le déterminant de sa partie linéaire vaut alors −1, et la copie brute aurait toutes ses cellules à l’envers (jacobien négatif, normales rentrantes sur une peau). Ces opérateurs appliquent donc en plus à chaque cellule la permutation de renversement, comme invert, de sorte que la copie ait la même orientation que la source et soit directement calculable. Les cas retournés dépendent de la dimension :

opérateur2D3D
symmetry_pointdirect (demi-tour)retourné
symmetry_lineretournédirect (demi-tour)
symmetry_plane— (3D seulement)retourné

Appliquer invert au résultat redonne la connectivité miroir brute.

    import math

    import pyrucast

    # A TRI3 face (a single triangle) in the z = 0 plane.
    c = pyrucast.Coords(dim=3)
    face = pyrucast.Mesh(c, "TRI3")
    face.unit().add_cell(
        [
            c.add_node([1.0, 0.0, 0.0]),
            c.add_node([2.0, 0.0, 0.0]),
            c.add_node([1.0, 0.0, 1.0]),
        ]
    )

    # Copy translated by 5 along +z (new nodes; `face` is left intact).
    haut = pyrucast.mesh.translate(face, [0.0, 0.0, 5.0])

    # Copy rotated by 30° about the z axis through the origin.
    tournee = pyrucast.mesh.rotate(face, math.pi / 6, [0.0, 0.0, 0.0], [0.0, 0.0, 1.0])

    # Mirror copy in the y = 0 plane, given by three of its points: the missing
    # half of a part meshed on its half-model (cells put back the right way
    # round).
    autre_moitie = pyrucast.mesh.symmetry_plane(
        face, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]
    )

Projection sur une sphère : project_on_sphere

project_on_sphere(mesh, center, radius) renvoie, elle aussi, une copie neuve : chaque nœud — sommets et nœuds milieux — est poussé le long du rayon issu de center, jusqu’à la distance radius. La copie garde les zones, les types, les couleurs et la connectivité de la source ; un nœud partagé le reste, donc une boucle fermée reste fermée.

Dans un plan qui passe par center, la sphère est un cercle : un contour de ce plan s’y pose nœud pour nœud, chacun à son propre angle. C’est la façon de construire le trou d’une couronne à partir du contour qui l’entoure : un nœud du trou en face de chaque nœud du contour, ce que sweep demande.

c = pyrucast.Coords(2)
coins = [c.add_node(p) for p in ([2.0, -2.0], [2.0, 2.0], [-2.0, 2.0], [-2.0, -2.0])]
carre = pyrucast.mesh.consolidate(
    pyrucast.mesh.line(coins[0], coins[1], 2)
    | pyrucast.mesh.line(coins[1], coins[2], 2)
    | pyrucast.mesh.line(coins[2], coins[3], 2)
    | pyrucast.mesh.line(coins[3], coins[0], 2)
)

# Every node of the square pushed onto the circle of radius 1: one node of
# the hole facing each node of the square.
trou = pyrucast.mesh.project_on_sphere(carre, [0.0, 0.0], 1.0)
couronne = pyrucast.mesh.sweep(carre, trou, 3)
print(couronne.cell_count)  # 24

radius doit être positif et fini, center avoir la dimension du maillage. Un nœud posé sur center n’a pas de direction : c’est une erreur, et aucun nœud n’est créé. Côté Rust, ops::mesh::project_on_sphere(&mesh, &center, radius).

Tissage d’un solide entre deux surfaces : sweep_solid

sweep_solid(mesh_a, mesh_b, n_layers) est le compagnon 3D de sweep : là où ce dernier relie deux lignes SEG2 par une bande de QUA4, sweep_solid relie deux surfaces par un solide. Les faces TRI3 deviennent des prismes PENTA6, les faces QUA4 des hexaèdres HEX8.

La cellule i de mesh_a est appariée à la cellule i de mesh_b, nœud local par nœud local. Les deux maillages doivent être mono-sous-maillage, du même type de surface (TRI3 ou QUA4), avec le même nombre de cellules et une correspondance de nœuds cohérente, sur le même Coords. Les n_layers couches de nœuds intermédiaires sont interpolées linéairement ; les nœuds des deux faces d’extrémité sont réutilisés.

Associé à translate / rotate, il construit une tranche de solide entre une surface et sa copie déplacée :

# `face` and `tournee`: the TRI3 face above and its copy rotated by 30°.
solide = pyrucast.mesh.sweep_solid(face, tournee, 1)
print(solide.element_types())  # ['PENTA6']

Révolution : revolve

revolve(mesh, angle, n_layers, center, axis=None) est le compagnon rotatif d’extrude : là où extrude translate le maillage source couche après couche le long d’un vecteur, revolve le fait tourner d’un angle total angle (en radians), en n_layers couches d’angle égal. Les montées de type sont les mêmes — SEG2→QUA4, TRI3→PENTA6, QUA4→HEX8 — et l’ordre des nœuds par cellule aussi (couche basse puis couche haute). En quadratique (SEG3, TRI6, QUA8), le nœud milieu d’une arête dans le sens de la rotation est pris au demi-angle : il est sur l’arc.

  • En 2D, la révolution se fait autour du point center (sens direct pour un angle positif) ; axis est ignoré. Seul un SEG2 a du sens : une surface engendrerait un solide, que des coordonnées 2D ne peuvent pas porter (c’est refusé explicitement).
  • En 3D, elle se fait autour de la droite passant par center dirigée par axis (main droite) ; axis est alors obligatoire et n’a pas besoin d’être normé.

Comme pour extrude, la couche 0 réutilise les nœuds de la source (les nœuds partagés entre cellules le restent) et les autres couches sont créées.

Un tour complet referme l’anneau. Avec angle = 2π, la dernière couche de nœuds est la première : le tore/cylindre engendré n’a ni couture ni nœuds en double, et rien à souder après coup (pas de merge_nodes). Au-delà d’un tour, la révolution se recouvrirait elle-même : c’est une erreur.

Une ligne qui touche l’axe s’y referme en triangles. Un nœud posé sur l’axe ne bouge pas : il reste le même nœud à chaque couche. Une maille de ligne qui s’y appuie par un sommet ne balaie donc pas un quadrangle mais un triangle — SEG2 → TRI3, SEG3 → TRI6 —, son sommet répété retiré et son ordre gardé. Les triangles d’une zone forment un sous-maillage à part, après ses quadrangles. Un rayon du centre au bord devient ainsi un secteur de disque : un éventail de triangles autour du nœud central, un anneau de quadrangles autour de l’éventail ; extrudé, il donne des prismes au centre et des hexaèdres autour. Une maille de surface ne peut pas toucher l’axe — son prisme s’écraserait —, et une ligne couchée sur l’axe ne balaierait rien : les deux sont refusées.

# A radius from the centre to the rim, turned by 90° in 7 sectors: a fan of
# triangles around the centre node, a ring of quadrangles around the fan.
centre = c.add_node([0.0, 0.0])
bord = c.add_node([0.0, -1.0])
secteur = pyrucast.mesh.revolve(
    pyrucast.mesh.line(centre, bord, 2), math.pi / 2, 7, [0.0, 0.0]
)
print(secteur.element_types())  # ['QUA4', 'TRI3']

Angle négatif. Il balaye dans l’autre sens et retourne les cellules, exactement comme un extrude à contre-normale : passez par orient sur le résultat, ou révolutionnez d’un angle positif depuis la source symétrisée.

import math

import pyrucast

c = pyrucast.Coords(dim=2)
a = c.add_node([1.0, 0.0])
b = c.add_node([2.0, 0.0])

# A complete annulus: the radial segment [1, 2] revolved a full turn into
# 32 QUA4 sectors — closed back on itself, with no seam.
rayon = pyrucast.mesh.line(a, b, 4)
couronne = pyrucast.mesh.revolve(rayon, 2 * math.pi, 32, [0.0, 0.0])
print(couronne.element_types(), couronne.cell_count)  # ['QUA4'] 128

# In 3D: a quarter of a tube, the QUA4 section swept about the z axis.
c3 = pyrucast.Coords(dim=3)
section = pyrucast.Mesh(c3, "QUA4")
section.unit().add_cell(
    [
        c3.add_node([1.0, 0.0, 0.0]),
        c3.add_node([2.0, 0.0, 0.0]),
        c3.add_node([2.0, 0.0, 1.0]),
        c3.add_node([1.0, 0.0, 1.0]),
    ]
)
quart = pyrucast.mesh.revolve(section, math.pi / 2, 8, [0.0, 0.0, 0.0], [0.0, 0.0, 1.0])
print(quart.element_types())  # ['HEX8']

revolve fait d’un coup ce que rotate + sweep_solid font tranche par tranche : une couche de revolve équivaut exactement à sweep_solid(face, rotate(face, angle, …), 1). Le passage par sweep_solid reste utile quand les deux faces ne se déduisent pas l’une de l’autre par une rotation.

Passage à l’ordre quadratique : to_quadratic

to_quadratic(mesh) construit la copie quadratique (Lagrange-2) d’un maillage linéaire : chaque type d’élément est promu vers son homologue quadratique — SEG2→SEG3, TRI3→TRI6, QUA4→QUA8, TET4→TET10, PENTA6→PENTA15, HEX8→HEX20.

Les nœuds sommets sont réutilisés (refcount incrémenté) ; un nœud de milieu d’arête est créé par arête distincte, au milieu géométrique de l’arête, et partagé entre toutes les cellules (tous sous-maillages confondus) qui utilisent cette arête — le résultat reste donc conforme. Le maillage d’origine n’est pas modifié. Un sous-maillage POI1 ou déjà quadratique lève une erreur.

Les nœuds milieux sont au milieu des cordes : sur un bord courbe, la copie quadratique reste un polygone. Pour suivre une courbe, on maille directement en quadratique — arc ou circle en SEG3, puis sweep ou extrude —, et les nœuds milieux sont alors sur la courbe.

Le maillage obtenu se calcule avec l’interpolation LAGRANGE2, que l’espace éléments finis prend de lui-même (cf. Espace éléments finis) :

lin = pyrucast.mesh.triangulate_surface(contour, "TRI3", 1.0)  # TRI3 mesh
quad = pyrucast.mesh.to_quadratic(lin)  # TRI6 copy
print(quad.element_types())  # ['TRI6']

fes = pyrucast.FiniteElementSpace(quad, interpolation="LAGRANGE2")

Changement de type d’élément : convert

convert(mesh, element_type) change le type d’élément de chaque sous-maillage vers element_type, en découpant chaque cellule en cellules du type cible — sans jamais déplacer ni ajouter de nœud sur les sommets existants. Trois cas sont couverts :

  • identité — element_type est déjà le type du sous-maillage : celui-ci est recopié tel quel ;
  • QUA4→TRI3 — chaque quadrangle est coupé en deux triangles selon la diagonale (0, 2) : (0, 1, 2) et (0, 2, 3) ;
  • HEX8→TET4 — chaque hexaèdre est coupé en six tétraèdres partageant la grande diagonale (0, 6) (subdivision de Freudenthal/Kuhn), un découpage qui pave l’espace et reste conforme entre hexaèdres voisins (faces coupées selon la même diagonale).

Les nœuds sommets sont réutilisés (aucune création, aucun déplacement) et les couleurs de face sont conservées. Le maillage d’origine n’est pas modifié. Tout autre couple (source, cible) lève une erreur : passer à un type quadratique (TRI3→TRI6, …), qui crée des nœuds de milieu d’arête, relève de to_quadratic.

faces = pyrucast.mesh.skin(volume)  # QUA4 skin
faces = pyrucast.mesh.convert(faces, "TRI3")  # QUA4 → TRI3
print(faces.element_types())  # ['TRI3']

Maillage d’un contour fermé : triangulate_surface

triangulate_surface(contour, element_type, size=None) remplit l’intérieur d’un contour par triangulation de Delaunay contrainte (CDT) puis raffinement de Ruppert à taille de maille cible, en créant les nœuds internes nécessaires. C’est l’équivalent de l’opérateur Cast3M SURF. Le mailleur est rapide (≈ 3·10⁵ mailles/s) et gère nativement les trous et plusieurs domaines disjoints en une passe.

Le contour est figé : le maillage produit réutilise exactement les nœuds d’entrée (mêmes identifiants, mêmes positions) et n’ajoute aucun nœud sur une arête du contour. Le raffinement n’insère donc que des nœuds intérieurs ; pour un bord plus fin, discrétisez le contour en amont (mesh.line(a, b, 15), mesh.arc(...), mesh.circle(...)).

contour est un Mesh contenant une ou plusieurs boucles SEG2 fermées ; la configuration peut être en dimension 2 (cas direct) ou une boucle plane en 3D (voir Contrôle de planéité plus bas). element_type vaut "TRI3" ou "QUA4" ; size fixe la longueur d’arête visée (par défaut : longueur moyenne des segments de bord de chaque domaine).

Les fondements mathématiques (aire signée, Newell, Delaunay / Bowyer-Watson, CDT, Ruppert) sont rassemblés dans Triangulation : briques mathématiques. Cette page-ci décrit le comportement de triangulate_surface.

Convention d’orientation (à la charge de l’appelant)

triangulate_surface s’appuie sur l’orientation des boucles fournies :

  • une boucle antihoraire (CCW, aire signée > 0) est la frontière extérieure d’un domaine ;
  • une boucle horaire (CW, aire signée < 0) est un trou, contenu dans une boucle extérieure ;
  • plusieurs boucles CCW disjointes maillent plusieurs domaines en une fois.

C’est exactement l’orientation produite par border (extérieur CCW, trous CW), donc la sortie de border réalimente directement triangulate_surface.

Méthode

  1. les points de bord sont insérés un à un dans une triangulation de Delaunay par Bowyer-Watson (super-triangle englobant) ;
  2. chaque arête de boucle absente est récupérée (retrait du corridor + ear-clipping des deux polygones adjacents) puis marquée contrainte ;
  3. la triangulation est légalisée (flips de Delaunay ne traversant aucune contrainte) ;
  4. excavation : un flood-fill depuis un triangle sûrement intérieur, ne traversant jamais une arête contrainte, sépare l’intérieur du domaine des trous et des poches hors d’un bord concave ;
  5. raffinement de Ruppert : les triangles trop plats/trop grands sont coupés par insertion de leur circoncentre. Le contour restant figé, un circoncentre qui empiéterait une arête de bord (ou tomberait hors du domaine) est abandonné plutôt que de couper cette arête — on préserve le contour au prix d’un triangle un peu moins bon près du bord ;
  6. léger lissage laplacien, puis (pour QUA4) recombinaison gloutonne des paires de triangles.

En QUA4 le résultat est donc quad-dominant : les triangles sont recombinés par paires en quadrangles, une poignée de triangles de bord pouvant subsister (sous-maillage TRI3 annexe). Les éléments sont orientés CCW.

Contrôle de planéité (cas 3D)

Une boucle 3D est ajustée à son plan de meilleure approximation (méthode de Newell), maillée dans ce repère 2D local, puis relevée dans l’espace 3D. La déviation maximale d’un nœud du contour à ce plan doit rester inférieure à 1e-6 × diag (diag = diagonale de la boîte englobante) ; au-delà, triangulate_surface retourne une erreur indiquant la déviation observée et la tolérance. Ce seuil relatif tolère le bruit numérique tout en refusant les vrais contours gauches.

Raffinement — convergence

Le raffinement de Ruppert garantit théoriquement sa convergence pour un angle minimal ≤ 20.7° (Shewchuk) ; pyrucast vise 20° et plafonne le nombre d’insertions pour éviter les divergences (erreur explicite si la limite est atteinte). Les nouveaux nœuds (« Steiner ») sont strictement intérieurs et créés dans la Coords du contour, exactement comme les nœuds utilisateur ; aucun n’est posé sur le contour, qui reste figé. Cette contrainte peut laisser subsister, contre un bord grossièrement discrétisé, un triangle plus plat que l’angle visé : affiner alors le contour d’entrée plutôt que la taille cible.

Exemple Python

import pyrucast

c = pyrucast.Coords(dim=2)

# Outer contour: 4×4 square (CCW).
outer = pyrucast.Mesh(c, "SEG2")
outer_nodes = [
    c.add_node(list(p)) for p in [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)]
]
for i in range(4):
    outer.unit().add_cell([outer_nodes[i], outer_nodes[(i + 1) % 4]])

# Hole: centred 2×2 square, oriented CW.
hole = pyrucast.Mesh(c, "SEG2")
hole_nodes = [
    c.add_node(list(p)) for p in [(1.0, 1.0), (1.0, 3.0), (3.0, 3.0), (3.0, 1.0)]
]
for i in range(4):
    hole.unit().add_cell([hole_nodes[i], hole_nodes[(i + 1) % 4]])

# Compose the two contours through the union | (never +).
combined = outer | hole

# TRI3 mesh of size ~0.5 (area = 16 - 4 = 12).
tri = pyrucast.mesh.triangulate_surface(combined, "TRI3", size=0.5)
print(tri.element_types(), tri.cell_count)

# Quad-dominant variant.
quad = pyrucast.mesh.triangulate_surface(combined, "QUA4", size=0.5)
print(quad.element_types())  # ['QUA4', 'TRI3'] in general

Refus final

Comme tous les mailleurs, triangulate_surface mesure ses mailles avant de les rendre et sort en erreur plutôt que d’en livrer une retournée ou plate — un jacobien négatif ou nul n’est intégrable par aucun code éléments finis. Il le gagne par construction (le flood-fill garde les triangles dans le sens du super-triangle, les bascules et le lissage refusent tout mouvement qui en retournerait un, la recombinaison n’apparie que des quadrangles convexes), mais la vérification est faite quand même : une garantie que rien ne contrôle n’en est une que tant que les quatre tiennent.

Interruption

Un maillage trop long s’interrompt par Ctrl+C : triangulate_surface sonde les signaux et lève une KeyboardInterrupt. Côté Rust, triangulate_surface_cancellable(contour, type, size, &cancel) accepte un jeton d’interruption (timeout, drapeau partagé…) — voir Interrompre une fonction.

Limitations actuelles

  • Taille uniforme : pas encore de champ de densité variable par nœud (un size façon CHPO1).
  • L’orientation est à fournir par l’appelant ; une boucle mal orientée mène à une erreur (aucune boucle extérieure) ou à un domaine inattendu.
  • Les boucles doivent être deux à deux disjointes (pas de trous emboîtés, pas de croisements).

Côté Rust, ops::mesh::triangulate_surface(&contour, ElementType::TRI3, Some(0.5)). Le cœur (CDT + raffinement) opère sur de simples Vec<Point2> sans toucher à la Coords ; lissage et recombinaison QUA4 sont parallélisés (rayon). Le module pyrucast::ops::mesh::triangulation regroupe par ailleurs les briques géométriques réutilisables indépendamment du système Mesh (voir Triangulation).

Pavage frontal d’un contour fermé : pave_surface

pave_surface(contour, element_type, size=None, prefer_quads=False, relax="free") remplit le même contour que triangulate_surface, mais en posant directement des quadrangles, par rangées qui avancent depuis le bord vers l’intérieur. C’est la version quadrangle de l’opérateur Cast3M SURF.

Pourquoi un second mailleur de surface

triangulate_surface accepte "QUA4", mais il triangule d’abord et recombine ensuite les triangles deux par deux : ce qu’on obtient dépend de la chance des appariements, les valences sont désordonnées et rien n’est aligné sur le bord. pave_surface ne recombine pas. Il pose des rangées parallèles au contour, ce qui est précisément la structure recherchée en éléments finis, où les gradients de contrainte et de flux sont les plus forts près des frontières.

triangulate_surfacepave_surface
méthodeDelaunay contraint + Ruppertfront avançant
élément naturelTRI3QUA4
QUA4 obtenu parrecombinaison de pairesconstruction
rangées alignées sur le bordnonoui
quadrangles seulsimpossiblepréférés (prefer_quads=True), sans garantie

Convention

Identique à triangulate_surface, et volontairement : les deux opérateurs partagent leur lecture de contour. contour est un Mesh d’une ou plusieurs boucles SEG2 fermées, chacune dans un seul sous-maillage (pyrucast.mesh.consolidate), orientées par l’appelant — CCW pour une frontière extérieure, CW pour un trou. Plusieurs boucles CCW disjointes pavent plusieurs domaines indépendants en une passe. La configuration peut être en dimension 2, ou une boucle plane en 3D (ajustée à son plan de meilleur approximation par la méthode de Newell, pavée dans ce plan, puis relevée).

Le contour est figé : les nœuds d’entrée sont réutilisés tels quels (mêmes identifiants, mêmes positions), ne sont jamais déplacés, et aucun nœud n’est ajouté sur une arête de bord. Voir Le contour est intouchable ci-dessous.

element_type vaut "QUA4", "QUA8" ou "QUA9" (les formes quadratiques sont dérivées du maillage QUA4). size fixe la longueur d’arête visée ; par défaut, la longueur moyenne des segments de bord du domaine.

Méthode

Le front part du bord du domaine et avance vers l’intérieur. Il est toujours un ensemble de boucles simples et disjointes, matière à gauche ; cet invariant n’est jamais supposé, il est maintenu.

  1. Une rangée par tour. À chaque nœud du front, le nombre de quadrangles voulus est \( k = \operatorname{round}(\theta / 90°) \), borné à \( 1..4 \), où \( \theta \) est l’angle intérieur. Ce n’est pas un seuil réglé : si \( k \) quadrangles entourent le nœud, ses voisins forment un chemin de \( k+1 \) sommets, dont \( k-1 \) sont neufs — vouloir des angles droits fixe \( k \). Les nouveaux nœuds se placent sur les rayons qui découpent le secteur en \( k \) parts égales.

  2. Refus et retrait. Un quadrangle non strictement convexe a un jacobien négatif à son coin rentrant : aucun code éléments finis ne peut l’intégrer. Une rangée qui en produirait un, dont les arêtes croiseraient le front, ou qui laisserait sa boucle retournée, est refusée ; le planificateur dit quels nœuds sont en cause, et la rangée est reprise moins loin à cet endroit seulement. Le retournement est le cas subtil : une rangée consomme de la matière, elle ne peut donc que rétrécir la région bornée par sa boucle, jamais en inverser le sens. Celle qui le fait est celle posée sur une lèvre — le résidu que deux fronts se rencontrant de face laissent entre eux, déjà plus mince que la maille voulue. Ses quadrangles passent tous les tests locaux, sa chaîne ne croise rien, et la boucle ressort à l’envers ; à partir de là chaque rangée suivante la gonfle au lieu de la réduire, jusqu’à sortir de la matière et entraîner avec elle toute couture rencontrée. Le signe de l’aire orientée distingue les deux cas sans ambiguïté — une boucle extérieure décroît vers zéro par le haut, le front d’un trou s’en éloigne par le bas, et chacune garde son signe sa vie durant. La lèvre refusée est laissée à la fermeture, qui la remplit ou la soude sur place.

  3. Détente. Une rangée fraîchement posée garde les coudes que le placement sur les bissectrices lui a laissés, et ils se composent : un nœud à 216° offre à ses voisins un secteur qu’aucun gabarit ne remplit bien, la rangée suivante est refusée et la boucle cale. Le front est donc relâché le long de lui-même — un nœud n’a de voisins engagés que derrière lui, un laplacien ordinaire le ramènerait d’où il vient. Chaque déplacement est pesé sur les quadrangles que le nœud porte déjà ; mais ceux-ci sont tous derrière lui et ne voient rien de la ligne devant, si bien que deux portions du front peuvent glisser l’une sur l’autre sans qu’aucun test local ne bronche. La boucle est donc aussi interrogée sur sa simplicité, et une détente qui la lui coûte est reprise en entier. Sans cela, un cercle à vingt côtés maillé au cinquième de son rayon rendait une boucle croisée, donc irremplissable, et le mailleur abandonnait ; il le pave. Mesuré une fois par appel plutôt qu’une fois par passe : +14 % contre +31 % sur un maillage que le front pose seul, pour les mêmes cas sauvés — et gratuit dès qu’une grille fait le travail.

  4. Couture. Deux nœuds de front qui se rapprochent à moins d’environ une demi-maille sont identifiés. La même opération scinde une boucle quand les deux nœuds lui appartiennent — c’est ainsi qu’une géométrie concave se divise — et joint deux boucles sinon — c’est ainsi qu’un trou est absorbé. Les trous n’ont donc aucun traitement particulier.

    Identifier réécrit sur le survivant toutes les mailles qui utilisaient l’autre nœud, triangles compris : un triangle resté accroché au nœud abandonné pendant que ses voisins déménagent est une maille pendue à un nœud que plus rien ne touche, soit trois fissures d’un coup. Et le survivant peut être un nœud du contour — il doit l’être dès que l’autre en est un, puisqu’un nœud du contour ne s’abandonne jamais. Deux coutures successives peuvent alors poser les deux bouts d’une même arête sur deux nœuds du contour qui en ont un troisième entre eux : cette arête longe le bord en enjambant un nœud, et la lentille d’aire nulle qui reste n’est refermable par aucune soudure, tous ses nœuds étant intouchables. Elle est recousue : la maille qui porte la corde reprend en elle les nœuds qu’elle a sautés. Elle ne grossit de rien, la lentille ne couvrant rien, et le bord redevient entier.

  5. Déblocage. Une boucle qui n’avance plus est coupée en deux par une corde, et les deux moitiés reprennent le pavage.

  6. Fermeture. Une boucle réduite à six nœuds ou moins est remplie par décomposition, sans jamais découper une arête (ce qui laisserait un nœud en T, donc un maillage non conforme). Encore faut-il qu’elle soit remplissable : une boucle qui se croise ne l’est pas, ses deux lobes tournant en sens contraire, et toute décomposition en laisse au moins un morceau retourné. Le signe de l’aire ne suffit pas à les repérer — quand les deux lobes se valent, l’aire qu’ils enferment s’annule presque et le signe qui reste est celui du plus gros, un accident. La boucle est donc aussi interrogée sur sa simplicité ; si elle est mince, elle est soudée comme une lèvre. C’est ce qui manquait : sur un cercle, deux mailles ressortaient à jacobien négatif.

  7. Nettoyage topologique, puis lissage sous garde de validité qui ne déplace jamais un nœud du contour. Dans cet ordre : lisser un nœud qui n’a pas le bon nombre de mailles autour de lui ne fait qu’étaler l’erreur sur ses voisins.

  8. Refus final. Chaque maille est mesurée : aucune ne sort d’aire nulle ou négative. Toutes les étapes ci-dessus ont déjà leur garde, exacte et locale ; celle-ci est le filet sous toutes les autres, et ce qu’elle attrape est une garde qui a laissé passer quelque chose. Le maillage n’est alors pas rendu : l’opérateur sort en erreur, en situant la pire maille. Une maille retournée a un jacobien négatif et une maille plate n’en a pas ; ni l’une ni l’autre n’est intégrable, donc un maillage qui en porte une n’est pas un maillage médiocre mais un maillage faux. Mieux vaut une erreur qu’un maillage faux — c’est la règle, et elle vaut pour tous les mailleurs.

Le nettoyage corrige ce que le lissage ne peut pas atteindre, parce que c’est de la connectivité et non de la géométrie :

  • un doublet — un nœud intérieur n’ayant que deux mailles autour de lui, qui partagent donc deux arêtes. Il reste coincé dans un coin quelles que soient les positions ; fusionner les deux mailles supprime le nœud et le coin d’un coup ;
  • une valence fautive. Un nœud intérieur veut quatre mailles : avec trois, les angles valent 120° en moyenne, avec cinq, 72°, et aucun lissage n’y peut rien puisque les angles autour d’un nœud somment à 2π quelles que soient les positions. Deux mailles voisines forment un hexagone, qui se recoupe selon l’une de ses trois diagonales ; changer de diagonale déplace une unité de valence. C’est le seul geste, il ne change ni le nombre de nœuds ni le bord, et il n’est appliqué que s’il fait strictement baisser l’erreur de valence.

Le nombre de mailles voulu à un nœud est le même \( \operatorname{round}(\theta / 90°) \) que dans la classification des rangées, avec ici \( \theta \) la somme des angles incidents : \( 2\pi \) à l’intérieur — d’où le quatre familier — et moins au bord, ce qui donne trois le long d’une arête droite et deux dans un coin droit. Une seule formule, aucun cas particulier « nœud de bord ».

Toutes les décisions topologiques — convexité, croisement de segments — passent par le prédicat exact orient2d (technique de Shewchuk, partagé avec le mailleur volumique). Ce ne sont donc pas des estimations.

Le contour est intouchable

Tous les nœuds du contour reviennent dans le maillage, à leur position, et aucun nœud n’est jamais ajouté sur une arête de bord. La discrétisation du bord appartient à l’appelant : elle porte en général les conditions aux limites, et un nœud glissé au milieu d’un segment serait un nœud que personne n’a demandé. Rien dans le paveur ne découpe une arête de bord, et la couture — la seule opération qui abandonne un nœud — refuse d’abandonner un nœud de contour.

Corollaire : un contour avec lequel le paveur ne peut pas travailler est signalé, pas contourné. Deux cas, tous deux renvoyés en erreur nommant le problème :

  • prefer_quads sur une boucle à nombre impair de segments. Un polygone à nombre impair de côtés n’admet aucun remplissage en quadrangles seuls ; le pavage ne peut pas changer cette parité — une rangée la conserve, une couture retire deux nœuds — et rééquilibrer le compte reviendrait à ajouter un nœud au bord ;
  • un contour si grossier, ou si irrégulier pour la taille demandée, que le front se replie sur lui-même en laissant une région impossible à remplir. L’erreur indique l’endroit.

Préférer les quadrangles : prefer_quads

Laissé à lui-même (prefer_quads=False), un contour impair coûte simplement un triangle, rendu dans un sous-maillage TRI3 séparé — avec les quelques mailles qu’un polygone résiduel trop déformé n’a pas pu rendre carrées : la fermeture préfère deux triangles à une maille de jacobien négatif, et la validité n’est jamais échangée.

Avec prefer_quads=True, la parité devient une exigence sur l’entrée : discrétisez chaque boucle de bord avec un nombre pair de segments — c’est votre donnée, une boucle impaire est refusée. Le pavage tient ensuite le front pair jusqu’au bout : chaque rangée, chaque couture, chaque découpe garde des boucles paires. Ce qu’il ne fait pas disparaître, ce sont les triangles de forme : un polygone résiduel trop déformé pour se couper en quadrangles valides est encore fermé par des triangles, puisque la validité passe avant. D’où le nom : une préférence, pas une garantie.

Relaxation du front : relax

Après chaque rangée, la chaîne fraîchement posée est relaxée — c’est ce qui empêche le front de se plisser. Cette relaxation est un laplacien, et un laplacien arrondit les angles. Cela coûte plus qu’il n’y paraît : un front ne perd des nœuds qu’aux endroits où son angle intérieur en demande moins de deux, c’est-à-dire à ses coins. Une fois les coins arrondis, il garde tous ses nœuds pendant que son périmètre rétrécit, son écartement se dégrade rangée après rangée, et le milieu du domaine sort plus fin que la taille demandée.

Le carré 20 × 20 à la taille 1 le montre sans ambiguïté : il contient 400 mailles unitaires, et le paveur devrait les poser. Ses quatre coins sont des fins de rangée, et c’est une fin de rangée qui fait perdre à la rangée les huit nœuds que le périmètre perd.

relaxmaillespire mailleaire médiane
"free" (défaut)5920,6190,62
"along"4001,0001,00
"none"4001,0001,00
  • "free" — le nœud va où le laplacien le pousse. C’est le comportement historique, et le seul qui ne laisse jamais le front se plisser.
  • "along" — le même déplacement, projeté sur le front : l’écartement s’égalise encore, la forme n’est plus rabotée.
  • "none" — le front reste exactement où la rangée l’a posé.

Il n’y a pas de bonne réponse universelle, et c’est pourquoi c’est un choix. "along" et "none" gagnent sur tout ce qui a des angles à garder ; sur une courbe, il n’y a rien à préserver et tout à redresser. Mesuré sur un carré et une bande à la taille 1, puis sur trois formes de Mailler une géométrie, à leur taille :

forme"free""along""none"
carré 20 × 20592 mailles, pire 0,619400, 1,000400, 1,000
bande étroite 40 × 3123, 0,702120, 1,000123, 0,684
L à cotes quelconques111, 0,68066, 0,78066, 0,774
créneau, base coupée816, 0,118586, 0,426577, 0,546
cercle R = 1547, 0,081610, 0,281620, 0,027

Le cercle ne se laisse départager par aucun réglage : selon la taille demandée, chacun des trois passe devant ou tombe près de zéro (de 0,001 à 0,281, voir Mailler une géométrie). Sur une courbe, ne comptez pas sur relax : prenez triangulate_surface ou grid_surface.

Un front qui n’arrive pas à converger ne s’obstine pas : le pavage s’arrête sur une erreur qui le dit, plutôt que de rendre un maillage fait de ce qui restait.

Exemple Python

import pyrucast as pc

# … CCW outer contour and CW hole circle, each consolidated into one loop.
# Each loop of the contour has an even number of segments, so prefer_quads works.
plaque = pc.mesh.pave_surface(contour, "QUA4", size=0.05, prefer_quads=True)
print(plaque.element_types())  # ['QUA4']

# The prismatic solid then comes for free, and in pure hexahedra.
volume = pc.mesh.extrude(plaque, [0, 0, 0.02], 2)
print(volume.element_types())  # ['HEX8']

Interruption

Le pavage interroge les signaux Python entre deux rangées : Ctrl+C pendant un maillage long lève KeyboardInterrupt. Côté Rust, la forme pave_surface_cancellable(..., cancel) prend un jeton Cancel.

Qualité

La qualité d’une maille est la mean ratio de son pire coin, \( 2(a \times b) / (|a|^2 + |b|^2) \), qui ne vaut 1 que si l’angle est droit et les deux arêtes égales. Pas le seul sinus de l’angle : celui-ci donne 1,000 à un rectangle 10:1, et laisse donc la garde du lissage écraser une maille tant qu’elle garde ses coins droits.

Sur une plaque percée, le cœur du maillage est fait de rectangles à angle droit et de valence régulière — aucune maille inversée, angle médian à l’équerre, très large majorité de nœuds intérieurs à quatre mailles — ce qui est exactement ce qu’on demande à un maillage quadrangulaire.

La faiblesse résiduelle est l’élancement : les mailles sont d’équerre mais sensiblement plus longues que larges. C’est précisément ce que la mean ratio fait apparaître là où le sinus le cachait, puisqu’un rectangle 10:1 obtient un jacobien normalisé de 1,000 et une mean ratio bien inférieure. Les deux mesures disent la même chose de la forme ; une seule des deux la voit.

Coût

Le coût est essentiellement linéaire en nombre de mailles, sur plusieurs ordres de grandeur, tant que le front avance sans se coincer : le front croît comme la racine du nombre de mailles, et l’index spatial est reconstruit à chaque rangée pour ce prix-là. Il augmente nettement quand les blocages se multiplient, la boucle repassant alors par les cordes de déblocage et les fermetures.

Le temps se répartit en gros en trois tiers : la pose des rangées, le nettoyage topologique et le lissage final. triangulate_surface va plus vite sur la même géométrie, mais laisse une part notable de triangles en QUA4.

Pièges

  • Une boucle par sous-maillage. Comme pour triangulate_surface, une boucle fermée doit tenir dans un seul sous-maillage : pyrucast.mesh.consolidate après avoir uni les côtés.
  • Orientation. Un trou doit être CW. pyrucast.mesh.invert retourne un cercle construit en CCW.
  • La taille du contour compte. Le front part de la discrétisation du bord et converge vers size en quelques rangées. Un contour beaucoup plus grossier que size donne donc des premières rangées plus grosses que demandé.

Limitations actuelles

  • La taille des mailles n’est pas uniforme : l’espacement le long du front et la distance d’avance ne sont pas asservis l’un à l’autre, d’où un élancement médian proche de 2 et une taille d’arête étalée d’un facteur 10. Les angles, eux, restent droits.
  • Deux fronts qui se rejoignent de face laissent normalement un éclat de recouvrement, dégénéré et sans matière : il est écarté. Au-delà d’une demi-maille d’aire, en revanche, c’est une région perdue, et le paveur sort en erreur en indiquant l’endroit plutôt que de rendre un maillage troué.
  • Front convexe sans coin. Un front ne perd des nœuds que par couture, et une couture n’est acceptée que si elle laisse toutes les mailles valides. Un contour circulaire n’a aucun coin : son front garde donc son nombre de nœuds pendant qu’il se contracte, ses arêtes raccourcissent, et les rangées finissent par ne plus tenir. Le paveur s’en sort par des cordes de découpage, mais le débit s’effondre — de l’ordre de 10³ mailles/s sur un disque finement discrétisé, contre 10⁵ sur la plaque trouée. Une géométrie comportant des coins, ou un contour discrétisé près de la taille visée, ne rencontre pas ce cas.
  • Pas de champ de taille variable : size est uniforme par domaine.
  • Un bras étroit n’a pas d’intérieur. C’est la forme aiguë du défaut précédent. Dans un bras de quatre ou cinq mailles de large — une tour de créneau, une nervure — le front avance d’une maille depuis chaque paroi et les deux se rencontrent après deux rangées : il ne reste aucune place où poser des mailles franches, et toute la largeur est ligne de collision. La première couche épouse bien la paroi, mais elle est déjà écrasée par sa jumelle d’en face. Sur le profil crénelé, les tours sortent à un élancement médian de 1,32 contre 1,11 pour grid_surface, et le maillage entier coûte 407 mailles contre 271. Là encore, pour une forme faite de bras rectilinéaires, c’est grid_surface qu’il faut.
  • Un rectangle ne donne pas une grille. C’est la limite de fond, et elle n’est pas réparable dans un paveur frontal : quatre fronts partis des quatre côtés se rencontrent en quatre coutures diagonales convergeant vers deux points singuliers. Sur un rectangle 0,6 × 0,3 à 0,02, le paveur rend 650 mailles et 6 triangles là où la grille en demande 450. Pour une forme rectilinéaire, c’est grid_surface qu’il faut.

Cœur en grille, bande frontale : grid_surface

grid_surface(contour, element_type, size=None, band=0, prefer_quads=False, relax="free") prend la même entrée que pave_surface, rend la même chose, et tient la même promesse — le contour est intouchable. Seul l’intérieur est obtenu autrement.

Pourquoi

Les deux familles de mailleur quadrangulaire échouent aux endroits opposés.

Un front est parfait là où il part : sa première rangée épouse exactement le contour. Il est douteux là où deux de ses rangées se rencontrent, parce qu’il lui faut y réconcilier deux discrétisations qui n’ont aucune raison de s’accorder. C’est cette ligne-là qui porte les défauts de valence, les triangles résiduels et les mailles aplaties.

Une grille est le miroir exact : chaque maille qu’elle pose est un rectangle par construction, et toute sa difficulté est au bord.

Prendre l’intérieur de la grille et le bord du front ne laisse donc ni l’une ni l’autre faiblesse.

Méthode

  1. Les lignes de la grille viennent du contour, pas de la boîte englobante. Toute arête axiale assez longue pour être une caractéristique fixe une ligne à sa coordonnée, et les intervalles entre lignes consécutives sont subdivisés uniformément à peu près à size. Un contour sans aucune arête axiale — un cercle — ne fixe rien et on retombe proprement sur la grille uniforme.
  2. Les mailles entièrement dans la matière sont gardées. Une arête de bord posée exactement sur une ligne de grille ne coupe aucune des deux mailles qu’elle sépare : elle passe entre elles.
  3. Un nœud de grille qui tombe sur un nœud du contour est ce nœud — le même sommet, pas une copie. Le cœur rejoint donc le bord au lieu de s’arrêter à un cheveu de lui.
  4. Le cœur recule seulement là où il ne rejoint pas le contour. Une face du cœur dont les deux extrémités sont des nœuds du contour et qui est un segment du contour est finie ; ailleurs le cœur s’arrête près du bord, à une distance quelconque entre zéro et une maille, et la maille derrière cette face est retirée pour laisser au front une maille pleine où travailler. band en retire davantage.
  5. La bande à paver est le contour et le bord du cœur moins les arêtes communes : un segment parcouru une fois dans chaque sens ne borne rien. Sur un domaine rectilinéaire posé sur la grille, il ne reste rien du tout, aucun front ne tourne, et le maillage est la grille.
  6. Une boucle restante entièrement issue du cœur est gelée. Elle reste vivante — le front la voit, s’en écarte, se coud dessus — mais ne pose aucune rangée. Deux fronts vivants se rencontrent où ça tombe ; un seul front atterrit, sur une interface qu’on a choisie.

L’orientation est détectée sur le contour

Rien dans le contrat n’attache la grille aux axes du repère : son orientation est un choix purement interne. Elle est donc prise sur le contour — l’angle qui rend axiale la plus grande longueur de contour, cherché sur un quart de tour, ce que la symétrie d’ordre 4 d’une grille laisse de distinct.

Sans cela la méthode ne serait qu’un tour de passe-passe ne marchant que sur les formes qu’on a dessinées d’équerre. Rectangle 0,6 × 0,3, taille 0,02 :

angleavant la détectionaprès
0°450 mailles, 0 triangle, qualité 1,000identique
5°496 (28 tri), 64 % de mailles parfaites450, 0 tri, 100 %
15°470 (20 tri), 62 %450, 0 tri, 100 %
30°454 (20 tri), 61 %450, 0 tri, 100 %
45°500 (30 tri), 55 %450, 0 tri, 100 %

À 30° la pire maille était moins bonne qu’avec pave_surface seul. Le profil crénelé tourné de 23,7° retrouve lui aussi ses 4 032 mailles parfaites.

La boîte englobante s’en trouve resserrée, pas agrandie : elle est calculée sur les points déjà tournés. À 30° elle passe de 0,670 × 0,560 — soit 2,08 fois l’aire de la pièce, autant de mailles classifiées pour rien — à 0,6 × 0,3.

Deux garde-fous :

  • un contour sans direction dominante — un cercle, dont les angles d’arête sont répartis uniformément — garde les axes : tourner échangerait une orientation arbitraire contre une autre, au prix de la reproductibilité ;
  • les axes gagnent les égalités. Une forme déjà d’équerre est le cas que cette détection ne doit surtout pas casser, et il serait mauvais de le perdre pour un gain de la largeur d’un arrondi ailleurs. Un chanfrein à 30° sur une pièce par ailleurs droite ne fait donc pas tourner toute la grille.

Reste le cas des directions concurrentes : une pièce mêlant des bords à 0° et à 30° ne peut satisfaire que l’une des deux familles. C’est la plus longue qui est servie.

Le contour doit être discrétisé pour une grille

C’est la seule chose demandée à l’appelant, et c’est une vraie contrainte. Une grille ne peut rejoindre qu’un contour dont les nœuds tombent sur ses lignes, et ses lignes sont dictées par la forme elle-même.

Soit un profil de 0,6 de large fait de neuf créneaux. Les créneaux posent une ligne tous les 0,0667 ; des mailles de 0,00375 donnent donc 18 colonnes par créneau, à 0,0037037. Une base discrétisée d’un seul tenant en 160 segments de 0,00375 les manque toutes, de 1,2 % — assez pour qu’aucun nœud ne soit partagé et que tout le bord retombe sur le front. Couper cette base sous chaque créneau, pour que chaque morceau prenne ses 18 segments, ne coûte rien et partage tous les nœuds.

La règle est donc : couper chaque côté aux angles de la forme, et laisser chaque morceau prendre un nombre entier de mailles. Rien ne le vérifie, parce qu’un contour qui ne le fait pas n’est pas une erreur — il obtient simplement plus de bande et moins de grille.

Exemple Python

import pyrucast as pc

H = 0.02  # target size
coords = pc.Coords(2)

# An L shape. Each side is cut into a whole number of cells of size H, so all
# its nodes fall on the lines the grid will draw from the corners.
angles = [(0.0, 0.0), (0.6, 0.0), (0.6, 0.2), (0.3, 0.2), (0.3, 0.4), (0.0, 0.4)]
noeuds = [coords.add_node(list(p)) for p in angles]

contour = None
for i, a in enumerate(angles):
    b = angles[(i + 1) % len(angles)]
    n = round(((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5 / H)
    seg = pc.mesh.line(noeuds[i], noeuds[(i + 1) % len(angles)], n)
    contour = seg if contour is None else contour | seg
contour = pc.mesh.consolidate(contour)

maillage = pc.mesh.grid_surface(contour, "QUA4", size=H)
print(maillage.element_types())  # ['QUA4'] — not a single triangle
print(maillage.cell_count)  # 450: the exact grid of the L shape

Qualité

Le profil crénelé de 0,6 × 0,3 à sept angles rentrants, taille visée 0,00375 :

pave_surfacegrid_surface
mailles6 428 QUA4 + 26 TRI34 032 QUA4, 0 TRI3
qualité — min0,2291,000
qualité — médiane0,8541,000
mailles sous 0,73,13 %0
aire couverteexacteexacte

Un tiers de mailles en moins pour une qualité parfaite. Sur un rectangle 0,6 × 0,3 à 0,02 : 450 mailles de qualité 1, contre 650 et 6 triangles.

Pièges

  • band vaut 0 et c’est la bonne valeur. Le cœur recule déjà d’une maille partout où il ne rejoint pas le contour. Ne l’augmentez que pour éloigner volontairement l’interface du bord.

  • relax gouverne la bande, pas le cœur — la grille est posée droite quoi qu’il arrive. Voir la relaxation du front : sur une bande épaisse et rectilinéaire, "along" la garde structurée.

  • Pas de gradation, et c’est un choix. La grille est uniforme par intervalle entre lignes. Un quadtree la graduerait — règle 2:1 et gabarits de transition — mais le résultat ne vaut pas ce qu’il coûte, et la raison est géométrique, pas algorithmique : passer de \( n \) nœuds à \( n/2 \) le long d’une interface en gardant tous les angles droits est impossible. La maille qui absorbe la réduction a trois nœuds d’un côté et deux de l’autre — ce n’est plus un rectangle, par construction. Tout raffinement local conforme paie donc en angles à 45°, et un quadtree en paie beaucoup : la gradation par distance au bord fait des couronnes concentriques, chaque feuille de couronne a un côté raffiné, un bord à cinq arêtes n’admet aucun découpage en quadrangles (dans tout quadrillage \( 4Q = 2E_\text{int} + E_\text{bord} \), donc \( E_\text{bord} \) est pair), et il faut un triangle par feuille. Mesuré sur un rectangle : 60 % de mailles en moins, mais un dixième de triangles et un jacobien minimal qui tombe de 1,000 à 0,70.

    La seule gradation compatible avec des rectangles parfaits est celle qui ne change aucun compte de nœuds : espacer les lignes de la grille en progression géométrique. Elle est par axe — une colonne fine traverse toute la hauteur — donc pas localisable, mais elle garde le jacobien à 1. Ce n’est pas fait non plus.

Le contour, nœud par nœud

La garantie est plus forte que « le bord est respecté » : tous les nœuds du contour reviennent, à leur position, avec leur identité, et le maillage n’a aucune autre arête de bord. Un mailleur en grille ne l’hérite pas — il pose ses propres nœuds — il ne la tient que parce qu’un nœud de grille tombant sur un nœud du contour est ce nœud.

Deux drapeaux distincts le portent, et les confondre coûtait cher :

  • immobile — le nœud ne doit pas bouger au lissage : ceux du contour, et ceux du cœur, qui sont d’équerre par construction et n’ont rien à y gagner ;
  • inaliénable — rien ne doit l’abandonner : ceux du contour, et eux seuls. Un nœud du cœur est à nous ; la soudure peut le céder, et c’est ce qui lui permet de refermer une lèvre entre cœur et contour. Tant que les deux drapeaux n’en faisaient qu’un, ces lèvres restaient ouvertes : sur un cercle, 88 arêtes de bord n’appartenaient pas au contour, soit 22 trous.

Dégradation

Une région trop mince pour tenir une seule maille de cœur n’en reçoit aucune, et le front la pave seul, exactement comme pave_surface. Un contour hors grille reçoit une bande large et le même traitement. La dégradation est continue : au pire la qualité du paveur frontal.

Continue, mais bornée par le bas par la validité. Si le front s’emmêle au point de laisser une maille retournée ou plate, l’opérateur refuse au lieu de rendre le maillage : un jacobien négatif n’est pas une maille médiocre mais une maille fausse, qu’aucun code éléments finis n’intègre. Mieux vaut l’apprendre ici qu’à l’assemblage.

Sur un cercle — le cas dégradé par excellence, aucune arête axiale — il ne reste aucune fissure : toutes les arêtes de bord sont celles du contour. Il manque 0,18 % d’aire, les lèvres que la soudure referme. Ce n’était pas acquis : tant que la retraite du front pouvait poser des rangées à 1,5 % de la taille demandée, ces rangées laissaient 16 arêtes orphelines, soit 4 trous d’une maille.

La phase de la grille est déjà la bonne

Une fois l’orientation trouvée, on pourrait vouloir translater la grille selon ses axes pour améliorer la bande. Deux raisons de ne pas le faire, la seconde mesurée.

D’abord, la phase n’est pas libre dans les cas visés : le calage sur les arêtes du contour est un mécanisme de phase. Une ligne épinglée par une arête verticale passe exactement par elle, et fill subdivise chaque intervalle séparément, si bien que chaque ligne de forme est atteinte exactement. Il n’y a rien à gagner là où l’opérateur brille.

La phase n’est donc libre que lorsque rien n’est épinglé — une courbe. Un balayage de huit phases sur un cercle a été mesuré : la phase actuelle gagne sur tous les critères — le moins de mailles, le moins de triangles, et la seule à n’ouvrir aucune fissure.

Ce n’est pas un hasard : ancrer sur le coin de la boîte englobante fait passer les lignes de grille exactement par les points extrêmes du contour, qui sont ses points de tangence. Une phase quelconque les manque, et chaque ligne manquée se paie en mailles de raccord.

Lignes par nœud, rangées pliées : grid_surface2

grid_surface2(contour, element_type, size=None, band=0, prefer_quads=False, relax="free") prend les mêmes contours, rend le même maillage, respecte le même contour intouchable que grid_surface. Il ne le remplace pas : la façon de poser la grille diffère, et aucune des deux ne gagne partout.

La différence

grid_surface pose une ligne sur la coordonnée où repose chaque côté aligné, puis découpe l’espace entre deux lignes d’après le côté qui l’enjambe de bout en bout. Toutes les lignes sont droites et toute maille du cœur est un rectangle.

grid_surface2 donne à chaque nœud du contour la ligne qui le traverse, puis :

  • coupe en mailles entières tout intervalle valant entre deux et trois fois le pas moyen que porte le contour, et abandonne au-delà : un vide que le contour ne borde pas revient au front d’un seul tenant plutôt qu’en rangées qui n’existent que pour être érodées ;
  • effondre arête par arête toute bande plus mince que la moitié de ce pas — chaque arête se soude sur le nœud de contour d’une de ses extrémités, ou sur son milieu quand aucune n’en est un ;
  • va chercher le reste : un nœud de grille à moins d’un quart de maille d’un nœud du contour se déplace dessus ;
  • et juge chaque maille sur la forme qu’elle a finalement, pas sur les lignes dont elle vient.

Une rangée est donc une polyligne, pas une ligne. C’est tout l’intérêt : une même rangée peut rejoindre deux parois qui se font face à deux hauteurs différentes, ce qu’aucune droite ne sait faire. Une paroi découpée en dix peut ainsi faire face à une paroi découpée en onze — grid_surface doit en choisir une et envoie l’autre à la bande.

Lequel prendre

Mesuré sur les mêmes formes, à la même taille visée, pire maille en mean ratio :

formegrid_surfacegrid_surface2
rectangle, et toute forme pile sur la grille0,9990,999
plaque à marche hors grille0,4050,963
L à cotes quelconques0,4370,979
L dont les côtés découpent 5+6 contre 4+70,4210,963
L dont les côtés diffèrent d’un nœud0,3510,606
profil crénelé, base coupée sous chaque barre0,3820,916
profil crénelé, base d’un seul tenant0,3230,651
maison à toit à deux pentes0,4200,548, 1 triangle contre 11
carré à un angle arrondi0,2440,400
cercle R = 10,288, p5 0,7960,005 — à ne pas utiliser

La comparaison des quatre mailleurs surfaciques, figures à l’appui, est sur la page Mailler une géométrie.

Donc : grid_surface2 pour une forme rectilinéaire, d’autant plus si ses côtés n’ont pas été coupés aux angles qui leur font face ; grid_surface pour tout ce qui est courbe, où suivre les nœuds du contour revient à suivre le hasard de l’endroit où ses sommets sont tombés.

Exemple Python

maillage = pc.mesh.grid_surface2(contour, "QUA4", size=H)
# or, as a method:
maillage = contour.grid_surface2("QUA4", size=H)

Améliorer un maillage existant : regularize, cleanup, merge_triangles

Les trois opérateurs font sur n’importe quel maillage ce que les paveurs font déjà à leur propre ouvrage en fin de course. Ils sont séparés et composables : l’ordre utile est en général triangles, puis topologie, puis géométrie, mais rien ne l’impose.

Deux marges, et une seule est de la géométrie

Un maillage peut être mauvais de deux façons sans rapport, et il ne faut pas les confondre.

La géométrie, c’est où sont les nœuds, et c’est ce qu’un lissage corrige. La topologie, c’est qui est voisin de qui, et aucun lissage ne peut y toucher : un nœud intérieur entouré de trois quadrangles a des coins à 120° en moyenne, et les déplacer n’y changera rien puisque les angles autour d’un nœud somment à 2π quelles que soient les positions. D’où cleanup à côté de regularize et non dedans.

Le bord n’est jamais touché

Tout nœud porté par une arête de bord — une arête qu’une seule maille utilise — est épinglé : il ne bouge pas, et rien ne l’abandonne. Le maillage garde donc exactement le bord avec lequel il est entré, ce qui est la promesse que les paveurs font déjà sur le contour qu’on leur donne.

regularize — la géométrie

angular=True (défaut) utilise le lissage angulaire : chaque voisin n propose la position obtenue en amenant n → v sur la bissectrice de l’angle que n voit, à longueur inchangée, et on moyenne les propositions. Elle vise l’angle droit là où le laplacien ne vise que le barycentre.

Deux garanties tiennent quelle que soit la règle, parce que le balayage est celui des paveurs : aucun nœud de bord ne bouge, et une position n’est retenue que si toutes les mailles incidentes restent valides et que la pire qualité incidente ne baisse pas.

Les deux se valent désormais, et c’est mesuré. Sur le cercle pavé (60 côtés, taille 0,05, 1 236 mailles) après 60 balayages :

qualité minp1p5
brut0,3660,4710,800
laplacien0,8060,8070,863
angulaire0,7810,8070,865

Les voilà à égalité au premier centile, le laplacien devant sur la pire maille et l’angulaire devant au cinquième centile. L’angulaire dominait franchement les trois colonnes tant que le maillage brut portait ses nœuds irréguliers : depuis que cleanup sait effondrer l’étoile d’un nœud à trois mailles, il en reste peu, et c’est précisément sur ceux-là que la règle angulaire prenait son avantage. Les enchaîner — angulaire puis laplacien — reste ce qui marche le mieux.

cleanup — la topologie

Un doublet est un nœud intérieur n’ayant que deux quadrangles autour de lui, qui partagent donc deux arêtes : le nœud est coincé dans un coin qu’aucun lissage n’ouvrira. Une valence fautive est un nœud intérieur qui veut quatre mailles et en a trois ou cinq.

Trois gestes. Le premier est purement topologique ; les deux autres ne peuvent pas l’être, et c’est le cœur de l’affaire.

  1. Bascule de diagonale, pour une valence trop forte : deux quadrangles partageant une arête forment un hexagone, qui se recoupe selon l’une de ses trois diagonales. Elle ne change ni nœud ni bord, et n’est appliquée que si elle fait strictement baisser l’erreur de valence en laissant les deux mailles convexes.
  2. Effondrement de l’étoile, pour un nœud intérieur qui n’a que trois mailles autour de lui. Ni le doublet — qui en veut deux — ni la bascule — qui en veut deux quadrangulaires — ne l’atteignent.

Le second geste tient tout entier dans une identité. Autour d’un nœud portant \( q \) quadrangles et \( t \) triangles, chaque quadrangle pose deux arêtes qui ne le touchent pas et chaque triangle en pose une : l’étoile du nœud est bordée par un polygone à

\[ n = 2q + t \]

côtés. Réciproquement, une décomposition d’un \( n \)-gone en \( q' \) quadrangles et \( t' \) triangles sans nœud intérieur vérifie

\[ 2q' + t' = n - 2. \]

Avec \( q + t = 3 \), les deux se combinent en \( 2q' + t' = 2q + t - 2 \) : la redécoupe existe toujours, et toujours avec une maille de moins que l’étoile qu’elle remplace.

\( q, t \)bord de l’étoileavantaprèsmailles
3, 0hexagone3 quadrangles2 quadrangles3 → 2
2, 1pentagone2 quadrangles, 1 triangle1 de chaque3 → 2
1, 2quadrangle1 quadrangle, 2 triangles1 quadrangle3 → 1
0, 3triangle3 triangles1 triangle3 → 1

Trois mailles autour d’un nœud intérieur, c’est une de trop quelles qu’elles soient : les coins font 120° en moyenne et aucun lissage ne les redressera, puisque les angles autour d’un nœud somment à 2π quelles que soient les positions. C’est le seul geste de cleanup qui supprime un nœud, le seul qui change le nombre de mailles, et le seul qui déplace quoi que ce soit. La coupe est prise selon celle des rotations qui laisse le maillage au mieux, et le geste s’applique si l’un des deux progresse : l’erreur de valence baisse strictement, ou la pire des nouvelles mailles bat la pire des anciennes. Il est refusé net si une maille sortirait retournée.

Pourquoi il faut relaxer avant de juger

Supprimer un nœud étire mécaniquement l’anneau qui restait autour : les six nœuds de l’hexagone ne bougent pas, et deux mailles s’y logent là où il y en avait trois. Mesurée sur-le-champ, la redécoupe paraît donc presque toujours plus mauvaise que l’étoile qu’elle remplace — alors qu’elle sera bonne dès que les nœuds auront été relâchés, ce qui arrive toujours : les paveurs lissent après chaque rangée, et regularize est l’étape suivante de l’appelant.

C’est la différence de fond avec la bascule de diagonale, qui, elle, ne déplace aucun nœud : ce qu’elle mesure est définitif, et elle peut s’imposer un plancher de qualité immédiat (QUALITY_FLOOR, 70 % de ce qui était là) sans rien s’interdire d’utile. Le même plancher appliqué à l’effondrement supprime 53 gestes utiles sur 61 — mesuré.

D’où l’ordre retenu : appliquer la découpe, relaxer l’anneau, mesurer, et défaire entièrement si la pire maille du voisinage a baissé. Et lorsque le geste est gardé, la relaxation est gardée avec lui : juger sur des positions qu’on rejetterait ensuite reviendrait à mesurer un maillage que personne ne reçoit — c’est mesuré aussi, et cela coûtait une maille à 0,055 sur la maison.

Un dernier garde-fou tient l’entrée : rien n’est tenté si le geste n’apporte ni valence ni forme. C’est que le verdict ci-dessus juge le voisinage, donc localement, et ne voit pas qu’une maille médiocre ailleurs vient de devenir la pire du maillage. Le retirer fait gagner quelques nœuds réguliers de plus et perdre la garantie de non-régression sur la pire maille — l’échange est mauvais : une pire maille qui recule casse un calcul, treize nœuds irréguliers de plus ne se voient nulle part.

Aucun nœud du contour ne bouge, et aucun n’est abandonné : c’est la garantie qui tient, la même que pour les trois opérateurs, et celle avec laquelle raisonner. « Rien ne bouge du tout » n’en est pas une : cleanup déplace les nœuds des anneaux qu’il a effondrés, et seulement ceux-là. Partout ailleurs il rend vos nœuds tels quels, et le maillage que vous lui donnez n’est jamais modifié.

Les deux dernières lignes du tableau perdent un triangle chacune — deux d’un coup, donc la parité qui lie leur nombre aux arêtes de bord (voir merge_triangles ci-dessous) reste intacte.

La paire, que le geste précédent ne peut pas atteindre

Deux nœuds intérieurs reliés par une arête, dont l’un au moins manque d’une maille, forment un motif qu’aucun des gestes ci-dessus ne prend. Abandonner l’un des deux tout seul ferait tomber deux de ses voisins de quatre à trois : un nœud irrégulier échangé contre deux, et l’effondrement le refuse à juste titre.

Pris ensemble, le calcul s’inverse. Leurs étoiles se recouvrent sur les mailles qui portent l’arête commune, si bien qu’à eux deux ils n’en portent que \( \mathrm{val}(a) + \mathrm{val}(b) - 2 \) — et la même identité \( 2q' + t' = n - 2 \) recoupe ce qui les borde :

\( \mathrm{val}(a), \mathrm{val}(b) \)bordavantaprèsmailles
3, 3hexagone4 quadrangles2 quadrangles4 → 2
3, 4heptagone4 quadrangles, 1 triangle2 quadrangles, 1 triangle5 → 3

Le geste abandonne donc deux nœuds et deux mailles d’un coup. Un triangle présent dans l’étoile est repris, jamais créé ni perdu : la parité qui lie leur nombre au bord interdit d’en faire apparaître un, et la redécoupe de l’heptagone réclame précisément celui qui était là.

L’arithmétique de valence le justifie : les quatre mailles offrent seize coins et les deux qui les remplacent en offrent huit ; la paire en emporte six avec elle, l’anneau en rend deux, et les deux erreurs de la paire sont annulées. Le gain est de +2 sur une paire ordinaire et monte à +4 quand l’anneau porte un nœud de valence 5 à dépenser.

La paire est examinée avant le nœud seul : c’est le motif le plus spécifique et la meilleure affaire. Dans l’autre ordre, l’effondrement d’un nœud prend l’un des deux et la paire n’a jamais sa chance — mesuré.

Comme tout geste qui supprime un nœud, il se juge après relaxation : la qualité mesurée juste après la découpe baisse (0,535 à 0,485 en médiane), et la lire là conduirait à refuser précisément les gestes qui paient.

Sur un maillage frais sorti d’un paveur, cleanup ne trouve rien à faire — le paveur passe la même main en fin de course. Son intérêt est ailleurs : un maillage lu depuis gmsh, ou sorti de merge_triangles.

Le sens de parcours n’a pas à vous préoccuper

Toutes les mesures de qualité employées ici sont signées : une maille lue en sens horaire compte négatif, ce qui se lit comme retournée. C’est volontaire — c’est ce signe qui empêche le lissage de retourner une maille — et un maillage entièrement horaire n’a pourtant rien d’anormal : un paveur rend le sens du contour qu’on lui a donné, si bien qu’un domaine maillé depuis un contour extérieur inversé (invert) sort horaire.

Les trois opérateurs remettent donc chaque maille en sens trigonométrique à la lecture et rendent le maillage dans le sens où il est entré. Rien à faire de votre côté, et orient n’est pas un préalable.

merge_triangles — les triangles, par paires

En comptant les côtés de chaque face, \( 4Q + 3T = 2E_\text{int} + E_\text{bord} \), donc \( T \equiv E_\text{bord} \pmod 2 \) : le nombre de triangles a la parité du nombre d’arêtes de bord, qui est intouchable. Aucune suite d’opérations locales ne peut la changer, et un maillage à nombre impair de triangles en garde un.

C’est aussi pourquoi « effondrer un triangle en un nœud » ne marche pas : fusionner ses trois coins en un seul coûte une arête à chaque quadrangle voisin, qui devient triangle à son tour. Gain net nul.

Trois gestes, tous purement topologiques — aucun nœud ne bouge, aucun n’est créé :

  1. Fusion. Deux triangles partageant une arête font un quadrangle. C’est à prendre ou à laisser : il n’y a qu’un quadrangle possible, et s’il sort rentrant la paire est bloquée.

  2. Regroupement. En prenant un quadrangle voisin avec eux, les trois mailles font un hexagone — 3 + 3 + 4 arêtes moins deux fois les deux partagées — qui se coupe en deux quadrangles selon l’une de ses trois grandes diagonales. Là où la fusion n’offre qu’un candidat — à prendre ou à laisser — celui-ci en offre trois, et c’est ce qui débloque le gros des paires. Prendre deux quadrangles plutôt qu’un couvre en plus la configuration en éventail autour d’un nœud, et en retire encore quelques-uns sur une ellipse.

    Trois quadrangles donneraient un octogone, qui veut trois quadrangles et bien plus de façons de le couper. La limite est mise à deux : le rendement s’essouffle, pas la recherche.

  3. Marche. Un triangle et un quadrangle voisin font un pentagone, qui se redécoupe en quadrangle + triangle de cinq façons : le triangle avance d’une maille et va chercher un partenaire plus loin.

Le contour du motif recoupé n’est jamais modifié, dans les trois cas : ces gestes ne peuvent pas changer le bord du maillage, par construction.

La validité ne se négocie pas, ici comme dans les paveurs : deux triangles dont l’union n’est pas convexe restent deux triangles. Rappelez l’opérateur après un lissage — une fusion refusée le devient souvent une fois les nœuds déplacés.

Ce que la chaîne donne

Cercle pavé par grid_surface, 1 236 mailles dont 8 triangles :

maillestrianglesqualité minp1p5
brut1 23680,3660,4710,800
après la chaîne1 21600,7810,8030,841

Les triangles tombent à zéro — une seule passe de merge_triangles y suffit sur ce cercle — et le pire quadrangle passe de 0,366 à 0,781. Chaque maillage intermédiaire reste valide, et le tour répété converge au lieu de dériver.

Un point à connaître : la sortie brute a la queue basse plus mince qu’avant que cleanup sache effondrer une étoile à trois mailles — premier centile à 0,471 contre 0,643. C’est le prix du geste, qui supprime un nœud et laisse ses voisins un peu étirés là où il passe, le temps que le lissage suivant les reprenne. Il est largement rendu : trois fois moins de triangles à la sortie du paveur, et aucun à l’arrivée.

Couche limite hexaédrique : pave_volume

pave_volume(envelope, layers=1, thickness=None, size=None) remplit la même enveloppe fermée que triangulate_volume, mais met des hexaèdres là où ils comptent — dans la couche contre le bord, où les gradients de contrainte et de flux sont les plus raides et où la forme d’une maille décide de la précision — et laisse l’intérieur, où le champ est lisse, aux tétraèdres.

peau (QUA4 / TRI3)  ──►  décalage intérieur  ──►  HEX8 / PENTA6   (couche limite)
                                                        │
                     faces internes carrées ──► PYRA5   │           (le raccord)
                                                        ▼
                                 vide borné par des triangles seulement
                                                        │
                                          triangulate_volume  ──►  TET4

Pourquoi les pyramides ne sont pas facultatives

Les faces internes de la couche sont carrées, et un tétraèdre n’en a aucune. Découper chaque carré en deux triangles ne suffit pas : l’hexaèdre de l’autre côté continue de voir une face carrée, avec un nœud suspendu en son milieu. Le maillage n’est plus conforme et aucun solveur ne peut assembler à travers cette face.

La pyramide est le seul élément qui présente un carré d’un côté et des triangles de l’autre — c’est exactement ce que réclame le raccord. Ses fonctions de forme se réduisent à celles d’un QUA4 sur la base et restent linéaires le long des arêtes vers le sommet, ce qui assure la continuité des deux côtés : voir PYRA5.

Une fois chaque face carrée coiffée, ce qui reste du vide n’est plus borné que par des triangles, et le mailleur tétraédrique existant prend le relais — en mode strict, donc en réutilisant ces triangles tels quels : les deux parties du maillage se rejoignent nœud à nœud.

Un front, pas un décalage global

Décaler l’enveloppe entière d’une même distance est la version naïve, et elle lâche dès que le solide cesse d’être épais partout : un seul étranglement plafonne l’épaisseur de toutes les couches, sur toute la pièce. Le front fait trois choses à la place.

Il avance de ce que la place permet. Le pas de chaque nœud est borné par la distance du front à lui-même en ce point — la moitié de la distance à la facette la plus proche à laquelle il n’appartient pas. Là où le solide est épais la couche est pleine, là où il se pince elle s’amincit au lieu que tout le maillage renonce. Demander une couche vingt fois plus épaisse que la pièce ne produit donc plus une erreur mais une couche adaptée.

Il place ses éléments localement. Une facette qui ne peut pas avancer — parce que la maille sortirait retournée — reste où elle est pendant que ses voisines poursuivent. La marche ainsi créée est refermée par une paroi latérale, un quadrangle neuf tendu entre l’ancienne arête et la nouvelle. Son orientation n’est pas choisie mais forcée : si A avance et que sa voisine B reste, l’arête (u,w) qu’elles partageaient n’est plus empruntée que par B, dans le sens (w,u) ; il faut donc que quelque chose l’emprunte en (u,w), et emprunte la nouvelle arête en (w',u'). Le quadrangle [u, w, w', u'] fait exactement les deux, et le front reste une surface fermée et orientée.

Il se coud. Deux parties du front qui se retrouvent à distance de contact sont soudées, ce qui referme une région mince au lieu d’y laisser un éclat qu’aucune maille ne peut remplir. Deux critères, tous deux nécessaires : les deux nœuds ne doivent pas partager de facette — les souder l’écraserait, ce n’est pas une couture mais une dégénérescence — et leurs normales doivent se faire face. Sans ce second critère, deux nœuds voisins d’une même nappe lisse se soudent et replient la surface ; c’est ce qui rendait le cube impossible à mailler pendant la mise au point.

Il est lissé. Les nœuds qu’il a créés sont relâchés sous garde de validité, si bien qu’un pas raccourci par la place disponible ne reste pas en coude. La garde est le jacobien normalisé lui-même, pas un indicateur qui lui ressemblerait, et le balayage est de Gauss–Seidel : chaque déplacement est jugé sur le maillage tel qu’il est, donc la pire maille ne peut que s’améliorer.

Décaler n’est pas « déplacer chaque nœud selon sa normale »

Moyenner les normales des facettes incidentes donne une direction, et cette direction ne suffit pas. Déplacez le coin d’un cube de \( t \) le long de la normale moyenne \( (1,1,1)/\sqrt3 \) et chacune des trois faces ne s’écarte que de \( t/\sqrt3 \) : la couche est la plus mince là où la géométrie tourne, c’est-à-dire là où elle peut le moins se le permettre. Pire, au coin d’un tétraèdre la normale moyenne est tangente à l’une des faces incidentes, qu’un déplacement le long d’elle ne décale donc pas du tout. Aucun facteur d’échelle ne rattrape cela : c’est la direction qui est fausse.

Le nœud décalé doit être le point où les facettes incidentes, chacune poussée de \( t \) vers l’intérieur, se rencontrent :

\[ \mathbf{d} \cdot \mathbf{n}_j = -t \quad \text{pour chaque facette incidente } j, \]

soit trois équations à trois inconnues à un coin, davantage sur une surface lisse, moins sur une arête. Résoudre au sens des moindres carrés, par les équations normales \( (N^{\!\top}\!N)\,\mathbf{d} = -t\,N^{\!\top}\mathbf{1} \), couvre les trois cas d’un coup et rend l’intersection exacte quand elle existe. Au coin du cube : \( \mathbf{d} = -t(1,1,1) \).

Convention

envelope est une surface fermée de facettes QUA4 et/ou TRI3, de normales sortantes de la matière — même convention que triangulate_volume, si bien que la peau d’un maillage (skin) s’y branche directement. Ses nœuds sont réutilisés tels quels. Une enveloppe ouverte, ou dont les facettes se contredisent sur l’orientation, est refusée en nommant l’arête fautive.

layers couches sont poussées vers l’intérieur, chacune de thickness de profondeur ; thickness=None prend la longueur d’arête moyenne de l’enveloppe, ce qui donne des mailles à peu près cubiques. size est la taille visée pour le cœur tétraédrique.

Le résultat porte un sous-maillage HEX8 (issu des facettes carrées), un PENTA6 (des triangulaires), un PYRA5 (le raccord) et un TET4 (le cœur), chacun présent seulement s’il n’est pas vide.

Exemple Python

import pyrucast as pc

peau = pc.mesh.skin(solide)  # QUA4, outward normals
maille = pc.mesh.pave_volume(peau, layers=1, thickness=0.15, size=0.4)
print(dict(zip(maille.element_types(), maille.cell_counts())))
# {'HEX8': 54, 'PYRA5': 54, 'TET4': 408}

Pièges

  • Orientation. Une enveloppe retournée est refusée en le disant ; pyrucast.mesh.invert la remet à l’endroit.
  • Épaisseur. Une couche plus épaisse que le solide ne peut pas rentrer : le décalage retourne l’enveloppe et l’opérateur sort en erreur en nommant la couche fautive.
  • Le cœur peut refuser. Le mailleur tétraédrique travaille en mode strict, donc sans ajouter de nœud sur la surface intérieure ; s’il n’y arrive pas, l’erreur le dit et une couche plus mince, ou une enveloppe plus fine, est la réponse habituelle.

Qualité et coût

Mesuré sur des cubes de 6³ à 16³ mailles de peau, en --release (cargo test --release -- --ignored volume_report --nocapture) :

casmaillestempsdébitjacobien médianinversées
cube 6³, 1 couche4 0540,22 s18 700 /s0,3740
cube 8³, 1 couche7 4220,38 s19 500 /s0,3450
cube 12³, 1 couche16 4540,80 s20 600 /s0,4180
cube 16³, 1 couche35 0151,72 s20 300 /s0,4310

Le coût est linéaire, autour de 20 000 mailles/s et 50 µs par maille. Par type, sur le cube 16³ :

typeminimummédiane
HEX80,5770,987
PYRA50,1810,319
TET40,0400,431

La couche hexaédrique est donc quasi parfaite — c’est le but — et le minimum de 0,577 n’est pas un défaut mais la valeur exacte d’un coin à 60°, celle des hexaèdres qui suivent une arête du cube. Les pyramides sont les mailles les plus médiocres du lot, ce qui est attendu d’un élément de raccord aplati.

Une seconde couche coûte cher : le cube 6³ tombe à 2 100 mailles/s, la tétraédrisation du cœur devenant nettement plus difficile.

Ce que l’enveloppe devient

Elle est respectée à la maille près. Ses nœuds sont réutilisés tels quels (mêmes identifiants, mêmes positions), ils sont marqués immobiles donc le lissage ne les touche pas, et chaque facette devient exactement une face de maille — un QUA4 la face d’un HEX8, un TRI3 celle d’un PENTA6. Aucun nœud n’est ajouté sur le bord. La couture protège explicitement ce contrat : entre deux nœuds candidats, celui de l’enveloppe est toujours le survivant, et si les deux en sont, la couture est refusée.

C’est aussi pour cela que le cœur tétraédrique tourne en mode strict : en mode permissif il ajouterait des nœuds sur la surface intérieure et ne rejoindrait plus les pyramides. Cette exigence est la contrepartie du contrat, et la principale source d’échec.

Limitations actuelles

  • Deux cas sur huit échouent, tous deux au cœur : une plaque mince et un barreau, c’est-à-dire les géométries où le front se referme réellement sur lui-même. La couture soude bien, mais un repli partiel laisse un vide que le mailleur tétraédrique, en mode strict, ne sait pas remplir. C’est la limite principale aujourd’hui.
  • La profondeur des pyramides est fixée au quart de l’arête de leur base. Plus profondes, elles seraient mieux formées mais finiraient par se traverser.
  • Les parois latérales ne servent jamais sur les cas mesurés. Le mécanisme est là et testé — retenir une facette lève bien quatre parois et le front reste fermé — mais sur les huit géométries du banc, le plafonnement par la place suffit toujours à rendre les mailles valides, et aucune facette n’a besoin d’être retenue. C’est une capacité en réserve, pas un moteur en service.
  • Le plastering complet — remplir tout le volume d’hexaèdres par front avançant, sans cœur tétraédrique — reste un problème ouvert. Sandia, qui l’a inventé, l’a abandonné : la fermeture du vide central bute sur des obstructions topologiques qu’une méthode locale ne voit pas. Le cœur tétraédrique n’est donc pas un raccourci mais l’état de l’art.

Mailleur volumique : triangulate_volume

solide = pyrucast.mesh.triangulate_volume(
    enveloppe, size=None, allow_surface_nodes=False
)

Le compagnon 3D de triangulate_surface : il remplit l’intérieur d’une enveloppe fermée en TRI3 avec des TET4. Les normales de l’enveloppe doivent pointer vers l’extérieur de la matière ; une forme concave est admise, et une cavité interne n’est qu’une autre surface fermée dont les normales pointent vers le trou — elle se soustrait d’elle-même, sans argument dédié.

peau = pyrucast.mesh.convert(pyrucast.mesh.skin(solide_penta6), "TRI3")
volume = pyrucast.mesh.triangulate_volume(peau, size=0.3)

L’enveloppe est respectée exactement : ses nœuds sont réutilisés tels quels (mêmes NodeId, mêmes positions), et aucun nœud n’est posé sur la surface — les nœuds ajoutés le sont strictement à l’intérieur. Ce n’est pas un effort au mieux : avant d’écrire quoi que ce soit, l’opérateur vérifie que le bord du maillage produit est exactement l’ensemble des facettes reçues.

Ce qui se passe, et pourquoi

Le mailleur enchaîne cinq étapes. Chacune répond à une difficulté précise, et il vaut la peine de savoir laquelle, parce que les messages d’erreur les nomment.

1. Prédicats exacts

Tout repose sur deux questions posées des millions de fois : de quel côté du plan (a, b, c) se trouve d ? et le point e est-il dans la sphère passant par a, b, c, d ? Ce sont les signes de deux déterminants :

\[ \mathrm{orient3d}(a,b,c,d)=\begin{vmatrix} b_x-a_x & b_y-a_y & b_z-a_z\\ c_x-a_x & c_y-a_y & c_z-a_z\\ d_x-a_x & d_y-a_y & d_z-a_z \end{vmatrix} \qquad \mathrm{insphere}(a,b,c,d,e)=-\begin{vmatrix} a_x-e_x & a_y-e_y & a_z-e_z & \lVert a-e\rVert^2\\ b_x-e_x & b_y-e_y & b_z-e_z & \lVert b-e\rVert^2\\ c_x-e_x & c_y-e_y & c_z-e_z & \lVert c-e\rVert^2\\ d_x-e_x & d_y-e_y & d_z-e_z & \lVert d-e\rVert^2 \end{vmatrix} \]

orient3d vaut six fois le volume signé, et il est positif exactement quand le tétraèdre (a,b,c,d) est bien orienté au sens de TET4 — face 0-1-2 vue en sens direct depuis le nœud 3.

Ce qui compte n’est pas la précision de ces valeurs mais leur cohérence : si orient3d(a,b,c,d) répond « au-dessus », alors orient3d(b,a,c,d) doit répondre « en dessous », et un point ne peut pas être à la fois dans un tétraèdre et hors de ses quatre faces. En f64 nu, près d’une dégénérescence, cette cohérence tombe — et une seule réponse contradictoire corrompt le graphe d’adjacence. C’est de cette façon qu’un mailleur incrémental tourne en boucle ou produit des cellules qui se recouvrent.

Ni une tolérance ni un jitter n’y remédient : ils rendent le prédicat généralement juste, pas cohérent avec lui-même. triangulate_volume calcule donc le signe exact, par la technique des expansions flottantes de Shewchuk : une estimation en f64 comparée à une borne d’erreur rigoureuse, puis, dans le seul cas où le signe reste indécidable, une réévaluation en arithmétique exacte. Un cube, une grille régulière, des coins cosphériques sont ainsi décidés et non devinés.

2. Triangulation de Delaunay des nœuds de l’enveloppe

Construite point par point selon Bowyer-Watson : on supprime tous les tétraèdres dont la sphère circonscrite contient le nouveau point p, puis on rebouche la cavité en joignant p à chaque face de son bord. L’appartenance à la cavité est insphere > 0 et rien d’autre, ce qui garantit que la cavité reste étoilée — visible en entier depuis p — et donc que le rebouchage produit des cellules bien formées.

Les points cosphériques ne sont pas perturbés : insphere == 0 signifie simplement « hors cavité ». On obtient l’une des triangulations de Delaunay valides de la configuration dégénérée, choisie de façon cohérente.

3. Récupération du bord

La triangulation précédente pave l’enveloppe convexe des nœuds et ne doit rien à la surface d’où ils viennent : une arête de l’enveloppe peut être traversée par un tétraèdre, une facette percée par une arête. Avant de pouvoir distinguer l’intérieur de l’extérieur, chacune doit apparaître dans la triangulation.

C’est la partie difficile, et pour une raison de fond : certains polyèdres n’admettent aucune tétraédrisation sur leurs propres sommets. Le prisme tordu de Schönhardt est l’exemple d’école. Plus près de vous : sur les 64 façons de trianguler le bord d’un cube, la plupart ne se remplissent pas. Ce n’est donc pas un algorithme perfectible, c’est une question dont la réponse est parfois « il n’y en a pas ».

La récupération procède obstacle par obstacle — bascules locales, puis reconstruction de la poche qui bloque — et, quand elle n’y arrive pas, elle nomme l’arête ou la facette en cause plutôt que de rendre un maillage qui ne correspond pas à la surface reçue.

Reconstruire une poche : deux remplisseurs. Le premier cherche, en faisant croître un pavage cellule par cellule depuis la surface de la poche. Il est complet — lui seul peut prouver qu’aucun remplissage n’existe — mais il est exponentiel et s’arrête à quelques cellules. Le second ne cherche pas : il calcule l’unique candidat canonique, la triangulation de Delaunay des sommets de la poche, et lui pose une seule question — contient-elle chaque face de la surface de la poche ? Si oui, les cellules qui tombent à l’intérieur pavent exactement la poche, pour le prix d’une triangulation quel que soit son volume ; si non, il nomme la face sur laquelle il a buté.

Ce nom est une instruction, pas un diagnostic : en absorbant la cellule située de l’autre côté de cette face, celle-ci cesse d’être sur la surface de la poche, et l’obstacle ne peut pas se représenter. On repose alors une question strictement plus grande, sans arbre de recherche ni retour arrière. C’est ce qui rend abordable la récupération d’une facette prise dans un plan interne — la diagonale d’un quadrilatère de paroi d’extrusion, par exemple — là où la recherche exhaustive renonçait. La facette à récupérer est passée comme un mur à deux faces : la poche est coupée en deux le long d’elle et chaque moitié est remplie depuis son propre côté, de sorte qu’une facette contenue dans la triangulation est une facette récupérée.

Ce que cela ne fait pas. Une triangulation de Delaunay est ce qu’elle est : on ne peut pas lui demander une arête. Or une arête d’enveloppe manquante est manquante précisément parce qu’elle n’est pas de Delaunay. La retriangulation de cavité traite donc les facettes, pas les arêtes ; une arête bloquée reste l’affaire des bascules, et si elles n’y suffisent pas, du mode allow_surface_nodes.

4. Séparation matière / vide

Les facettes de l’enveloppe deviennent des murs. Deux tétraèdres séparés par un mur sont de part et d’autre de la surface ; toute autre paire de voisins est du même côté. L’intérieur s’obtient donc par inondation depuis des cellules connues intérieures — et « connue intérieure » découle de l’orientation que vous avez fournie, la matière étant du côté opposé à la normale.

L’inondation est menée des deux côtés, et les deux résultats doivent partitionner le maillage : chaque cellule intérieure ou extérieure, aucune les deux, aucune ni l’une ni l’autre. Une cellule qui échappe à cela est une fuite, signalée et non maillée en silence.

5. Qualité : raffinement puis chasse aux slivers

Un maillage valide n’est pas un maillage utilisable. Une tétraédrisation des seuls nœuds d’une surface contient toujours des cellules dont les quatre coins sont presque coplanaires, et une poignée suffit à rendre une matrice élémentaire singulière.

Raffinement de Delaunay. On prend la cellule la plus mal formée et on pose un nœud au centre de sa sphère circonscrite. Deux critères décident :

  • le rapport rayon-arête \( \rho = R/\ell \), rayon circonscrit sur arête la plus courte. Un tétraèdre régulier vaut \( \rho = \sqrt6/4 \approx 0{,}61 \) ; une aiguille ou un coin en donne un grand. Découper au-dessus d’un seuil \( B \) termine pour tout \( B > 2 \) — d’où le seuil juste au-dessus de 2 ;
  • la taille, \( R \) comparé à size. Pour un tétraèdre régulier d’arête \( a \), \( R = a\sqrt6/4 \), ce qui convertit la longueur d’arête demandée en rayon visé.

Une règle est indispensable à la terminaison : l’empiètement. Un nœud posé dans la sphère d’une facette du bord dégrade les cellules contre cette facette au lieu de les améliorer, et le raffinement le redemande sans fin. La réponse classique est de découper la facette ; l’enveloppe ne nous appartenant pas, la cellule est simplement laissée telle quelle.

C’est aussi ce qui borne size par le bas : on ne peut pas mailler plus fin que la surface ne le permet. Sur un cube à huit coins, tout centre circonscrit empiète, et size n’a aucun effet ; il faut une enveloppe déjà discrétisée à la finesse voulue.

Chasse aux slivers. Le raffinement ne peut rien contre le sliver : quatre coins proches d’un même plan, répartis régulièrement sur un cercle. Son arête la plus courte est honorable et sa sphère circonscrite petite — \( \rho \) ne voit rien d’anormal — alors que son volume est presque nul. C’est un théorème, pas une lacune d’implémentation : aucun nœud inséré ne le casse. Le maillage est donc amélioré plutôt que subdivisé, par les deux seuls mouvements qui ne changent pas ce qu’il remplit :

  • reconnexion : les mêmes nœuds, joints autrement ;
  • retrait d’arête : les arêtes du sliver ôtées, une à une ;
  • relaxation : les mêmes liens, un nœud déplacé — les nœuds intérieurs seulement, jamais les vôtres.

Les trois sont jugés sur le plus petit angle dièdre des cellules touchées et appliqués seulement s’ils l’améliorent, ce qui rend la passe monotone.

Le deuxième est celui qui tue réellement les slivers, et la raison est géométrique. Un sliver est plat en travers d’une arête : une bascule 2-3 sur une de ses faces n’a donc souvent nulle part où aller — la paire de cellules n’y est pas convexe — tandis que vider l’anneau de cellules autour de l’arête fautive et le remplir autrement a toujours la place de le faire. Le retrait d’arête généralise la bascule 3-2 et tout ce qui vient après.

Mesuré sur l’enveloppe de tutoriel/maillage.py (3 304 facettes), remplie à quatre tailles, sans puis avec cette passe. Le plus petit angle dièdre médian reste entre 44° et 47° dans tous les cas ; la dernière colonne est la forme \( \eta \) de la cellule la plus plate, 1 pour un tétraèdre régulier et 0 pour un plat :

sizemaillespart sous 10°part sous 1°\( \eta \) de la plus plate
0,0112 8500,36 % → 0,00 %0 → 0\( 9{,}3\cdot10^{-2} \) → \( 3{,}5\cdot10^{-1} \)
0,00597 5740,89 % → 0,005 %0,002 % → 0\( 1{,}6\cdot10^{-2} \) → \( 7{,}9\cdot10^{-2} \)
0,0033340 3291,99 % → 0,10 %0,017 % → 0,0003 %\( 8{,}3\cdot10^{-3} \) → \( 1{,}9\cdot10^{-2} \)
0,0025378 1900,88 % → 0,018 %0,0013 % → 0,0003 %\( 9{,}3\cdot10^{-3} \) → \( 2{,}7\cdot10^{-2} \)

La colonne sous 1° est l’enjeu : une cellule sous 1° d’angle dièdre donne une matrice élémentaire quasi singulière, et il n’en faut pas beaucoup pour couler un calcul. La passe coûte de 1,1× à 1,7× le temps de maillage, selon la taille.

Ce que cela a demandé. Une passe qui améliore un maillage doit être moins chère que sa construction, ce qui interdit deux réflexes. On ne teste pas en faisant puis défaisant : copier le maillage à chaque candidat coûte \( O(n) \) pour un échange qui coûte \( O(1) \), donc une passe qui fait \( O(n) \) échanges devient quadratique — mesuré, cela portait le maillage d’un million de mailles à 325 s au lieu de 77 s. Le remplacement de région décide donc avant de muter si les nouvelles cellules pavent bien la région, à partir de leurs seules faces. Et on ne paie pas la recherche exhaustive de remplissage pour une arête qu’on peut aussi bien laisser en place : la récupération du bord a besoin de complétude, une passe de qualité n’a besoin que d’une réponse.

Quand l’enveloppe ne convient pas

Deux refus vous sont rendus, tous deux avec un lieu et une action.

« cannot fit the envelope’s edge/facet … » — la surface ne peut pas être retrouvée dans la triangulation. Soit elle n’admet réellement aucun maillage sur ses nœuds, soit la récupération n’y arrive pas.

« the mesh has N flat cell(s) that cannot be improved … » — un sliver dont les quatre coins sont des nœuds de l’enveloppe. Rien à insérer pour le casser, rien à bouger puisque ses coins sont les vôtres, et le retrait d’arête n’y arrive pas non plus. C’est devenu rare : aucune des enveloppes de la suite de tests ne l’obtient plus. La mesure est \( \eta = 12\,(3V)^{2/3} / \sum \ell^2 \), qui vaut 1 pour un tétraèdre régulier et 0 pour un plat ; le seuil de \( 10^{-4} \) est calibré et non choisi — les cellules saines restent au-dessus de \( 4\cdot10^{-2} \), une cellule réellement plate tombe sous \( 10^{-7} \).

Dans les deux cas, la réponse est de rediscrétiser la surface à cet endroit — ou de laisser le mailleur le faire.

allow_surface_nodes : ce qu’on échange

solide = pyrucast.mesh.triangulate_volume(peau, allow_surface_nodes=True)

Autorise le mailleur à couper l’enveloppe plus fin là où il ne peut ni la retrouver ni la rendre utilisable.

Ce qui est conservé : la forme. Chaque nœud ajouté est posé sur l’arête ou la facette qu’il divise, donc la surface reste la même surface, seule sa triangulation s’affine. Ce qui est perdu : la discrétisation — la peau du résultat ne coïncide plus avec le maillage surfacique fourni. Cela compte si deux solides doivent partager une interface conforme ; cela ne compte pas si l’enveloppe ne servait qu’à décrire une forme. Un avertissement sur stderr indique combien de nœuds ont été ajoutés — et prévient que le maillage rendu porte de ce fait un sous-maillage de plus.

Et surtout, le résultat vous dit lesquels. Un message sur stderr n’est pas quelque chose sur quoi un script peut agir ; quand des nœuds ont été posés sur l’enveloppe, le maillage rendu porte un second sous-maillage de POI1 qui les nomme. Il n’apparaît que dans ce cas — un maillage obtenu sans rien ajouter n’a qu’un sous-maillage TET4 :

solide = pyrucast.mesh.triangulate_volume(peau, allow_surface_nodes=True)
if solide.element_types() == ["TET4", "POI1"]:
    ajoutes = solide.cell_counts()[1]
    print(f"{ajoutes} node(s) laid on the skin")

Ce sous-maillage se visualise, se soustrait, sert de support de champ comme n’importe quel autre. Attention en revanche si vous enchaînez : les opérateurs qui attendent un maillage volumique pur veulent le sous-maillage TET4 seul.

C’est aussi ce qui débloque. Une arête qu’on n’arrive pas à faire rentrer n’a autrement aucune issue ; la couper en deux scinde le problème en deux plus faciles, et une arête suffisamment entourée de ses propres subdivisions est récupérée par le Delaunay tout seul. Ce qui a été ajouté est ensuite rendu partout où le maillage veut bien s’en séparer : sur la plaque de tutoriel/maillage.py, 13 nœuds posés, 10 repris, 3 restants.

Pièges

Orientation après extrude. extrude ne vérifie pas que sa direction est du côté de la normale de la surface source. Un skin de son résultat revient donc souvent avec les normales rentrantes, et triangulate_volume le refuse en vous renvoyant vers invert. C’est le cas du pipeline triangulate_surface → extrude → skin ci-dessus.

Uniquement du TRI3. Un quadrangle n’a pas de plan unique à respecter ; il est refusé plutôt que découpé en silence. Passez par convert(peau, "TRI3").

Nœuds confondus. Deux nœuds distincts au même endroit déchirent la surface ; utilisez merge_nodes au préalable. Le mailleur le signale explicitement.

Coût

Sur la plaque percée de tutoriel/maillage.py, maillée, extrudée et pelée à la même taille que le remplissage :

sizefacettestétraèdrestemps
0,013 30412 8502,3 s
0,0068 09658 6696,6 s
0,00417 824195 91919 s
0,00330 616462 44751 s

Le coût est essentiellement linéaire en nombre de mailles produites. L’opération est interruptible : Ctrl+C pendant un long maillage lève KeyboardInterrupt sans rien laisser derrière.

Côté Rust, ops::mesh::triangulate_volume(envelope, size, allow_surface_nodes), et triangulate_volume_cancellable(…, cancel) pour la forme interruptible.

Bord d’une surface : border

border(mesh, angle_deg=None) est l’inverse de triangulate_surface : il prend un maillage de surface (cellules TRI3 / QUA4) et renvoie son bord sous forme de boucles SEG2 fermées. Une surface quadratique (TRI6, QUA8, QUA9) donne des boucles SEG3, qui reprennent le nœud milieu de chaque arête de bord ; une surface qui mêle zones linéaires et quadratiques est refusée.

Une arête de cellule utilisée par exactement une cellule est une arête de bord ; les arêtes intérieures sont partagées par deux cellules (orientations opposées) et s’annulent. Les arêtes de bord de tous les sous-maillages de surface sont regroupées — la sortie QUA4 + TRI3 de triangulate_surface donne donc un bord commun unique — puis chaînées en boucles fermées.

Le résultat est un Mesh avec un sous-maillage SEG2 par boucle : une seule boucle pour un domaine simplement connexe, plusieurs quand le domaine a des trous ou des morceaux disjoints. Chaque boucle garde l’orientation CCW du bord (boucle extérieure CCW, trous CW) : le résultat peut donc réalimenter directement triangulate_surface. Les nœuds d’origine sont réutilisés (et re-référencés).

import pyrucast

c = pyrucast.Coords(dim=2)
center = c.add_node([0.0, 0.0])
disc = pyrucast.mesh.triangulate_surface(
    pyrucast.mesh.circle(center, [0.0, 0.0, 1.0], 2.0, 16), "TRI3"
)

bord = pyrucast.mesh.border(disc)
print(len(bord))  # 1  (simply connected domain)
print(bord.element_types())  # ['SEG2']
print(bord.cell_counts())  # [16]

Découpe par angle (angle_deg)

Avec un angle_deg, chaque boucle est en plus découpée en arêtes ouvertes à ses coins — le pendant 1D du découpage en faces planes de skin. Un nœud est un coin quand le bord y tourne de plus de angle_deg degrés (l’angle entre les directions des arêtes entrante et sortante). Chaque arête — une suite maximale de segments quasi alignés entre deux coins — devient son propre sous-maillage SEG2 (un côté droit d’un carré, même subdivisé, reste une arête). Une boucle sans aucun coin (bord courbé dont tous les virages restent sous le seuil) est conservée comme une boucle fermée. angle_deg=None (défaut) garde chaque bord en une boucle fermée.

carre = pyrucast.mesh.triangulate_surface(contour_carre, "TRI3", 0.5)
aretes = pyrucast.mesh.border(carre, angle_deg=45.0)
print(len(aretes))  # 4  (the four sides, open edges)

Les sous-maillages POI1 (un point n’a pas d’arête) sont ignorés. La fonction lève une erreur si le maillage n’a aucune cellule de surface, s’il porte des cellules autres que POI1/TRI3/QUA4/TRI6/QUA8/QUA9 (les bords 1D et 3D ne sont pas gérés ici — voir skin pour le bord d’un volume), ou si le bord n’est pas un ensemble propre de boucles fermées (arête ouverte ou non-manifold).

Côté Rust, ops::mesh::border(&mesh, angle_deg).

Peau d’un volume : skin

skin(mesh, angle_deg=None) est le pendant 3D de border : il prend un maillage volumique et renvoie sa peau — la surface extérieure — découpée en faces planes, un sous-maillage par face.

Il accepte tous les types volumiques : TET4, PYRA5, PENTA6, HEX8 et leurs homologues quadratiques TET10, PENTA15, HEX20, HEX27. Chaque élément déclare lui-même ses facettes, si bien qu’aucun type n’est laissé de côté.

Une facette d’élément volumique (une face de TET4, de HEX8, …) utilisée par exactement une cellule est une facette de bord ; les facettes intérieures sont partagées par deux cellules et s’annulent. Les facettes de bord de tous les sous-maillages volumiques sont regroupées, puis réparties en faces planes : deux facettes adjacentes (partageant une arête) appartiennent à la même face tant qu’elles restent quasi coplanaires — l’angle entre leurs normales sortantes est inférieur ou égal à angle_deg degrés (défaut 1°).

Une facette est émise dans son propre type : un HEX8 donne des QUA4, un TET10 donne des TRI6, un HEX27 des QUA9. La peau d’un maillage quadratique est donc elle-même quadratique et conserve ses nœuds milieux — on peut la remailler ou poser un chargement dessus sans perdre le degré. L’appariement des facettes se décide sur leurs coins, ce sur quoi deux mailles voisines s’accordent quel que soit leur degré.

Chaque groupe devient un sous-maillage par type de facette (une face mêlant triangles et quadrangles, p. ex. à une interface PYRA5/HEX8, en produit un de chaque). Un cube donne ainsi six sous-maillages, un prisme cinq (deux chapeaux triangulaires, trois flancs quadrangulaires), une pyramide cinq (la base carrée et quatre triangles). Les facettes conservent leur orientation sortante ; les nœuds d’origine sont réutilisés (et re-référencés).

import pyrucast

# A PENTA6 block: a triangulated square, extruded along +z.
c = pyrucast.Coords(dim=3)
coins = [c.add_node(p) for p in [[0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0]]]
contour = pyrucast.Mesh(c, "SEG2")
for i in range(4):
    contour[0].add_cell([coins[i], coins[(i + 1) % 4]])
surf = pyrucast.mesh.triangulate_surface(contour, "TRI3", 0.34)
solide = pyrucast.mesh.extrude(surf, [0.0, 0.0, 1.0], 3)  # TRI3 -> PENTA6

peau = pyrucast.mesh.skin(solide)
print(len(peau))  # 6  (two caps + four sides)
print(peau.element_types())  # ['TRI3', 'TRI3', 'QUA4', 'QUA4', 'QUA4', 'QUA4']

Un angle_deg plus grand regroupe des faces plus courbées (un cylindre facetté devient une seule paroi) ; un angle_deg proche de 0 isole chaque facette. Les sous-maillages POI1 sont ignorés. La fonction lève une erreur si le maillage n’a aucune cellule volumique, s’il porte des cellules de dimension topologique inférieure (surface, ligne), ou si l’espace n’est pas 3D.

Côté Rust, ops::mesh::skin(&mesh, angle_deg).

Orientation des cellules : orient et invert

orient(mesh) (Cast3M ORIE) harmonise l’orientation des cellules d’un maillage, et invert(mesh) (Cast3M INVE) l’inverse. Les deux travaillent en toute dimension — segments SEG* (1D), faces TRI*/QUA* (2D), volumes TET*/PENTA*/HEX* (3D), variantes linéaires et quadratiques — et renvoient un maillage neuf qui reflète l’entrée sous-maillage par sous-maillage (mêmes types, mêmes couleurs, mêmes nœuds partagés) ; l’entrée est laissée intacte.

Cadre unifié : facettes orientées

Le bord orienté d’une cellule est une somme signée de ses facettes de codimension 1 :

  • SEG* (d = 1) : les deux nœuds extrémité — queue (−1) et tête (+1) ;
  • TRI* / QUA* (d = 2) : les arêtes orientées ;
  • TET* / PENTA* / HEX* (d = 3) : les faces orientées sortantes.

Chaque occurrence se réduit à un couple (clé, signe) où clé est la liste triée des nœuds (coins) de la facette et signe ∈ {−1, +1} encode son orientation par rapport à une orientation canonique de la clé. Deux cellules partageant une facette sont cohérentes ssi elles lui donnent des signes opposés. Les clés de dimensions différentes (1 / 2 / ≥ 3 nœuds) ne se confondent jamais : un maillage mixte se sépare en composantes par dimension.

orient — cohérence, pas de sens absolu

orient propage une orientation cohérente à travers les facettes partagées par un parcours en largeur du graphe dual. Chaque composante connexe est amorcée par sa cellule d’indice le plus bas, qui garde son orientation (choix déterministe, reproductible bit à bit) ; les autres sont retournées au besoin.

orient ne choisit pas de sens absolu « sortant » : pour une surface fermée il laisse le tout entièrement sortant ou entièrement rentrant selon la graine. Pour choisir le sens (p. ex. définir l’intérieur d’un trou), composer avec invert. Les facettes non-manifold (partagées par plus de deux cellules) n’imposent aucune contrainte et sont ignorées.

invert — retournement inconditionnel

invert applique à chaque cellule sa permutation d’inversion (ElementType::reversal_permutation, la réflexion échangeant les deux premiers axes de référence — pour SEG* la négation de l’axe unique). Les POI1 (sans orientation) sont inchangés. Appliqué deux fois, invert redonne le maillage de départ.

import pyrucast

# A plate with a hole: outer contour + hole border, arbitrary orientations.
surf = pyrucast.mesh.triangulate_surface(contour, "TRI3")

propre = pyrucast.mesh.orient(surf)  # every cell made consistent
trou_dedans = pyrucast.mesh.invert(propre)  # reversed sense (inside/outside)

Côté Rust, ops::mesh::orient(&mesh) et ops::mesh::invert(&mesh).

Mise en chaîne d’une ligne : chain

orient fait pointer les segments d’une courbe dans le même sens, mais les laisse là où ils sont dans la connectivité. chain(mesh) est le complément : il les met dans l’ordre du parcours, de sorte que lire les mailles l’une après l’autre revient à marcher le long de la courbe d’un bout à l’autre.

  5 6                    1 2
  1 2      chain  →      2 3
  3 4                    3 4
  2 3                    4 5
  4 5                    5 6

Chaque sous-maillage est chaîné indépendamment (il garde ses mailles, son type et sa couleur de face) et doit former une seule chaîne continue : chaque nœud porte un ou deux segments, et les mailles constituent un seul morceau connexe, ouvert (deux extrémités libres) ou refermé en boucle. Tout le reste est une erreur — un nœud à trois segments (embranchement), ou plusieurs morceaux disjoints. Il faut alors découper l’entrée en un sous-maillage par branche.

Les mailles sont retournées au besoin en chemin (le nœud milieu d’un SEG3 reste au milieu) : chain oriente donc aussi, inutile de passer par orient d’abord.

Par où la chaîne commence

Une chaîne ouverte se lit depuis une extrémité libre. Si exactement une des deux est déjà la queue de son segment, c’est elle qui amorce le parcours : une courbe déjà orientée de façon cohérente garde son sens, et chain se réduit à une permutation des mailles. Sinon (les deux extrémités sont des queues, ou aucune) c’est celle de plus petit numéro de nœud, ce qui rend le résultat déterministe. Une boucle fermée n’a pas d’extrémité libre : elle part de son plus petit numéro de nœud, dans le sens du segment qui l’a déjà pour queue.

import pyrucast

# A contour drawn from a surface: the segments are there, but in no order.
bord = pyrucast.mesh.border(surf)
suite = pyrucast.mesh.chain(bord)  # or bord.chain()

# The connectivity now reads node by node along the curve.
for maille in suite[0]:
    print([n.id for n in maille])

Côté Rust, ops::mesh::chain(&mesh) — ou la méthode mesh.chain().

Éléments s’appuyant sur des nœuds : elements_on

elements_on(mesh, points, strict=True) renvoie le sous-ensemble des éléments de mesh qui s’appuient sur les nœuds de points — l’opérateur historique ELEM … APPUYE. Seul l’ensemble des nœuds référencés par points compte (typiquement un maillage de points POI1) ; ni le type ni la connectivité de points n’importent.

Le critère dépend de strict :

  • strict=True — on garde une cellule lorsque tous ses nœuds sont dans l’ensemble (APPUYE STRICTEMENT) ;
  • strict=False — on garde une cellule dès qu’au moins un de ses nœuds y est (APPUYE).

Le résultat épouse la structure de mesh sous-maillage par sous-maillage (même ordre, mêmes types d’éléments, mêmes couleurs) : chaque sous-maillage de sortie porte les cellules retenues du sous-maillage d’entrée correspondant, éventuellement vide. Les zones restent séparées (jamais de fusion). Au besoin, mesh.consolidate(mesh) élimine ou fond ensuite les zones vides ou redondantes. Les cellules retenues réutilisent les nœuds d’origine (refcount incrémenté) ; mesh est laissé intact.

Les deux maillages doivent vivre sur la même Coords (un identifiant de nœud n’a de sens qu’au sein d’une Coords), sinon une erreur est levée. Un points vide ne retient rien.

import pyrucast

c = pyrucast.Coords(dim=2)
nodes = [c.add_node(p) for p in [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (2.0, 0.0)]]

mesh = pyrucast.Mesh(c, "TRI3")
mesh.unit().add_cell([nodes[0], nodes[1], nodes[2]])  # cell 0
mesh.unit().add_cell([nodes[1], nodes[3], nodes[2]])  # cell 1

# Points = {0, 1, 2}: only cell 0 has all of its nodes in there.
pts = pyrucast.mesh.poi1_from_nodes([nodes[0], nodes[1], nodes[2]])

strict = pyrucast.mesh.elements_on(mesh, pts, strict=True)
print(strict.cell_count)  # 1  (cell 0)

loose = pyrucast.mesh.elements_on(mesh, pts, strict=False)
print(loose.cell_count)  # 2  (both touch a node of pts)

Côté Rust, ops::mesh::elements_on(&mesh, &points, strict).

Sélection de nœuds par région géométrique : la famille points_*

Pour poser une condition aux limites il faut d’abord désigner des nœuds. La famille points_* répond à cette question par une région géométrique — l’équivalent de l’opérateur historique POIN … PLAN / DROIT / CYLI / SPHE :

points_<in|on|below>_<forme>(mesh, …géométrie…, tol=None) -> Mesh POI1

Toutes ces fonctions renvoient un maillage POI1 calqué sur l’entrée : un sous-maillage par sous-maillage de mesh, dans le même ordre, éventuellement vide. La sélection conserve donc le zonage de sa source — on sait de quelle zone vient chaque nœud, et on peut travailler zone par zone en indexant le résultat. mesh.consolidate(sel) retombe sur un nuage unique quand ce découpage ne sert pas.

Les nœuds sont dédoublonnés dans l’ordre de première apparition dans la connectivité, exactement comme to_poi1 — une sélection totale reproduit to_poi1 nœud pour nœud.

in et on

Deux familles, deux lectures :

  • points_in_* — dans la région fermée, élargie de tol ;
  • points_on_* — à moins de tol de la surface de la région, des deux côtés.

Deux formes sont fermées par des faces planes, et la distinction compte : points_on_cylinder et points_on_cone ne retiennent que la surface latérale, pas les disques d’extrémité — ceux-ci sont plats, c’est points_on_plane qui les coupe. Le tore, lui, est une surface fermée : la question ne se pose pas.

Le plan n’a pas de « dedans » : il a deux côtés, d’où points_below_plane, le demi-espace opposé à la normale (plan compris). Il n’existe pas de points_above_plane : retourner la normale donne l’autre moitié.

Enfin, la droite est infinie là où le cylindre est borné : pour une sélection le long d’un axe mais limitée au segment, c’est points_in_cylinder avec un petit rayon.

La tolérance tol

tol est la précision géométrique du test, mesurée comme une distance à la surface de la région. tol=None demande la valeur par défaut : 1e-6 × la diagonale de la boîte englobante du maillage. C’est ce qui rend les opérateurs sans échelle — le même appel marche sur une équerre en millimètres et sur un barrage en kilomètres — et ce qui rend points_on_plane utilisable sur des nœuds sortis d’un mailleur plutôt que d’une arithmétique exacte.

Pour le cône, la distance est prise perpendiculairement à la surface inclinée, et non radialement : la bande reste large de tol quelle que soit la pente.

Le cas « un seul nœud »

La requête du nœud le plus proche d’un point ne peut en renvoyer qu’un ; elle n’est donc pas dans cette famille et ne renvoie pas de POI1, mais un Node : c’est la méthode mesh.nearest_node([x, y]), des deux côtés — voir Opérateurs géométriques.

Repère de travail

Les nœuds sont testés dans les coordonnées où ils sont stockés. En axisymétrie, c’est le demi-plan méridien (r, z) et non le solide de révolution : une « sphère » y est un cercle du méridien. Le tore, qui a besoin d’un axe hors du plan pour être un tore, est 3D seulement.

import pyrucast

# A square plate meshed in TRI3.
plaque = pyrucast.mesh.triangulate_surface(contour, "TRI3", size=0.1)

# The left edge (x = 0): the plane of normal +x through the origin.
gauche = pyrucast.mesh.points_on_plane(plaque, [0.0, 0.0], [1.0, 0.0])

# The nodes of the fillet: inside the disc of radius 0.2 around the re-entrant corner.
conge = pyrucast.mesh.points_in_sphere(plaque, [1.0, 1.0], 0.2)

# The selection serves directly as the imposed support of a Dirichlet — the
# POI1 cloud is what `model.dirichlet` expects (cf. Constraints / Dirichlet).
mecanique = pyrucast.model.elasticity(
    pyrucast.FiniteElementSpace(plaque), "plane_stress"
)
blocage = pyrucast.model.dirichlet(gauche, mecanique, "u_x")

# The POI1 output is an ordinary mesh: it plugs back into the other operators,
# here to go back up to the elements carried by the selection.
bande = pyrucast.mesh.elements_on(plaque, conge, strict=True)

En 3D, les formes de révolution sélectionnent alésages, arbres et gorges :

# The bore of a tube: the lateral surface of the cylinder of inner radius.
alesage = pyrucast.mesh.points_on_cylinder(tube, [0.0, 0.0, 0.0], [0.0, 0.0, 10.0], 5.0)

# A conical chamfer (radius 8 at z = 0, fictitious apex at z = 8).
chanfrein = pyrucast.mesh.points_on_cone(piece, [0.0, 0.0, 0.0], [0.0, 0.0, 8.0], 8.0)

# The material around a toroidal groove of radius 1 on a circle of radius 5.
gorge = pyrucast.mesh.points_in_torus(piece, [0.0, 0.0, 3.0], [0.0, 0.0, 1.0], 5.0, 1.0)

Côté Rust, ops::mesh::points_on_plane(&mesh, &origin, &normal, tol) et consorts, avec tol: Option<f64>.

Soudure des nœuds proches : merge_nodes

merge_nodes(mesh, tol) soude entre eux les nœuds distants de moins de tol (distance euclidienne). C’est l’opération de « recollage » classique : quand deux morceaux maillés séparément se rejoignent le long d’une interface, leurs nœuds y sont colocalisés mais distincts ; merge_nodes les fond en un seul, rendant le maillage topologiquement connexe.

Chaque cluster de nœuds proches est représenté par un seul nœud — celui de plus petit identifiant —, et ce représentant garde ses propres coordonnées (aucune moyenne : on ne déplace jamais la géométrie en douce). La connectivité de chaque sous-maillage est réécrite pour pointer vers les représentants ; la structure de sous-maillages (types, ordre, couleurs) est préservée.

Un cluster est une composante connexe de la relation « distants de moins de tol » : la soudure se propage de proche en proche. Sur une chaîne a—b—c où a et b se touchent, b et c aussi, mais a et c non, les trois n’en font qu’un. C’est aussi pourquoi une tolérance de l’ordre de la taille de maille effondre toute une zone au lieu d’une seule interface : tol se choisit petit devant l’arête la plus courte, et la ligne de bilan est là pour le vérifier.

Coût. La recherche des voisins passe par une grille uniforme dont la maille ne descend jamais sous tol : chaque nœud ne visite que les cases que sa boule de rayon tol touche vraiment — une seule, dans le cas courant — et cette phase est parallélisée. Compter une poignée de secondes pour dix millions de mailles, la mémoire de travail restant de l’ordre de quelques octets par nœud.

Une cellule qui s’effondre — c’est-à-dire qui référence deux fois le même représentant après soudure (un SEG2 dont les deux bouts fusionnent, un TRI3 à deux coins confondus, …) — est abandonnée : elle est dégénérée. Les cellules POI1 (un seul nœud) ne s’effondrent jamais et sont toujours conservées ; dédupliquer des points colocalisés reste le rôle de mesh.consolidate, pas celui-ci.

tol doit être ≥ 0 ; tol = 0 ne soude que les nœuds exactement colocalisés. Seuls les nœuds référencés par le maillage sont concernés. Le maillage d’entrée est laissé intact ; les nœuds soudés disparaissent de la connectivité du résultat et deviennent récupérables par le GC de la Coords une fois plus rien ne les référence.

import pyrucast

# A mesh whose interface carries colocated but distinct nodes (two SEG2 that
# touch through a duplicated end).
c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
b2 = c.add_node([1.0, 0.0])  # on top of b, but a distinct node
d = c.add_node([2.0, 0.0])

mesh = pyrucast.Mesh(c, "SEG2")
mesh.unit().add_cell([a, b])
mesh.unit().add_cell([b2, d])

joined = pyrucast.mesh.merge_nodes(mesh, 1e-6)  # b2 is welded onto b

Bilan à l’écran. Chaque appel imprime une ligne sur la sortie standard — nœuds soudés, mailles supprimées, tolérance employée :

merge_nodes: 12 node(s) welded, 3 cell(s) dropped, tol = 0.000001
merge_nodes (in place): 12 node(s) welded, cells untouched, tol = 0.000001

C’est une étape qu’on veut voir passer dans un journal de construction : tol est un pari sur la géométrie, et cette ligne est ce qui dit s’il était bon. En place, la ligne ne parle pas de mailles supprimées : il ne peut pas y en avoir, l’opérateur refuse plutôt (voir plus bas).

merge_nodes opère au sein d’une même Coords (l’invariant du Mesh impose déjà une Coords commune à tous les sous-maillages). Deux pièces maillées dans des Coords séparées ne se soudent donc pas : il faut d’abord les amener dans la même Coords.

Souder sur place : in_place=True

Par défaut merge_nodes copie : il rend un maillage neuf et laisse ses entrées intactes. Les maillages d’origine, eux, gardent donc leurs nœuds dupliqués — ce qui oblige à ne plus manipuler qu’un troisième maillage, et à recâbler tout ce qui pointait vers les deux premiers.

merge_nodes(mesh, tol, in_place=True) réécrit à la place la connectivité des sous-maillages existants — effet de bord assumé et voulu — et renvoie le maillage lui-même. Comme l’union mesh_a | mesh_b partage les sous-maillages (elle ne les copie pas), souder l’union soude du même coup mesh_a et mesh_b :

gauche = pyrucast.mesh.line(a, b, 4)
droite = pyrucast.mesh.line(b2, d, 4)  # b2 colocated with b, but distinct

pyrucast.mesh.merge_nodes(gauche | droite, 1e-6, in_place=True)

# The two pieces now really share the interface node.
assert droite.node(0, 0, 0).id == b.id

Ce que la mutation ne touche pas : la structure du maillage. Mêmes sous-maillages, mêmes types, même nombre de cellules dans le même ordre — seul quel nœud une cellule référence change. C’est ce qui rend l’effet de bord tenable : tout indice déjà détenu sur ces sous-maillages (numéros de cellules, et donc les champs par élément qui s’appuient dessus) reste valide. Les coordonnées des nœuds ne bougent pas non plus.

D’où deux refus, vérifiés sur tout le maillage avant la moindre écriture (un appel rejeté ne modifie donc rien) :

  • une cellule qui s’effondrerait est une erreur, là où la variante copiante l’abandonne : l’abandonner changerait le nombre de mailles, c’est-à- dire précisément l’invariant sur lequel repose l’appel sur place. Baissez tol, ou passez par la variante copiante ;
  • un sous-maillage scellé est une erreur : un espace d’éléments finis, une matrice — ou un champ, si c’est un nuage POI1 qui lui sert de support — l’a capturé et lit sa numérotation de nœuds. Soudez avant de les construire (ou repartez d’un duplicate()).

Les caches dérivés de la connectivité (index des nœuds, compagnon POI1 de to_poi1) sont invalidés par la réécriture, et les refcounts suivent : chaque emplacement réécrit incrémente son nouveau nœud et décrémente l’ancien.

Le retour est exactement le maillage passé — les mêmes sous-maillages, dont l’intérieur a changé —, pas une copie : en Python, out is mesh. On peut donc l’ignorer, ou chaîner dessus, au choix.

Côté Rust, c’est le même opérateur avec le même drapeau : ops::mesh::merge_nodes(&mesh, tol, in_place) — miroir strict de la forme Python, qui n’ajoute que la valeur par défaut. La brique de conteneur sous-jacente est SubMesh::remap_nodes(&map), un renommage de nœuds à structure constante.

Lecture d’un maillage gmsh : read_gmsh

read_gmsh importe un maillage produit par gmsh (fichier .msh, versions MSH 2.2 et MSH 4.1, en ASCII comme en binaire — l’endianness est lue dans le fichier). L’appelant fournit la Coords dans laquelle lire (il garde ainsi la main sur les nœuds) ; le résultat est un dict Python qui associe à chaque groupe physique son Mesh :

import pyrucast

coords = pyrucast.Coords(dim=2)
regions = pyrucast.mesh.read_gmsh(coords, "piece.msh")
# {'plate': Mesh<…>, 'bottom': Mesh<…>, …}  — order of the file preserved

plate = regions["plate"]
print(plate.element_types())  # e.g. ['TRI3']
print(plate.cell_count)

Groupes, types et Coords partagée

  • Un Mesh par groupe physique, et un sous-maillage par type d’élément à l’intérieur de chaque groupe. Les éléments sans groupe physique sont rangés sous la clé "<ungrouped>".

  • Tous les Mesh partagent la Coords fournie : un nœud à la frontière de deux groupes (p. ex. un nœud du bord "bottom" qui appartient aussi à la surface "plate") est le même nœud des deux côtés — pas un doublon. Comme c’est votre Coords, vous gardez le handle pour poser des conditions aux limites sur une région nommée lue dans le fichier :

    coords = pyrucast.Coords(dim=2)
    regions = pyrucast.mesh.read_gmsh(coords, "piece.msh")
    plate = regions["plate"]
    bottom = regions["bottom"]  # même Coords que plate
    
    fes = pyrucast.FiniteElementSpace(plate)
    # ... assemblage sur 'plate', blocage des nœuds de 'bottom', etc.
    

    La Coords peut déjà contenir de la géométrie : l’import s’y ajoute. Si vous égarez le handle côté Python, mesh.coords() le récupère depuis n’importe quel Mesh du dict.

Types d’éléments reconnus

Les codes gmsh sont traduits vers les types pyrucast ; l’ordre local des nœuds coïncide déjà avec le repère de référence, la connectivité est donc copiée telle quelle.

Code gmshType pyrucast
1SEG2
2TRI3
3QUA4
4TET4
5HEX8
6PENTA6
7PYRA5
15POI1
8SEG3
9TRI6
16QUA8
10QUA9
11TET10
17HEX20
18PENTA15
12HEX27

Pour les types quadratiques volumiques (TET10, HEX20, PENTA15, HEX27), gmsh numérote les nœuds de milieu d’arête (et de face pour HEX27) dans un ordre différent de la convention pyrucast (VTK) : la connectivité est réalignée à la lecture (même permutation que meshio). Tout autre type gmsh (ordre 3 et plus…) lève une erreur explicite.

Dimension

gmsh stocke toujours trois coordonnées par nœud ; c’est la dimension de la Coords fournie qui décide combien sont conservées. On lit donc dans une Coords(dim=2) pour aplatir un maillage planaire sur xy, ou dans une Coords(dim=3) pour garder le relief.

Seuls les nœuds référencés par un élément sont matérialisés dans la Coords ; les nœuds isolés listés mais utilisés par aucun élément sont ignorés.

read_gmsh_str(coords, text) fait la même chose à partir du texte du fichier déjà chargé en mémoire (utile pour les tests ou un .msh reçu sur le réseau). Côté Rust, ops::mesh::read_gmsh(coords, path) et ops::mesh::read_gmsh_str(coords, text) renvoient un Vec<(String, Mesh)> ordonné.

Déléguer le maillage à gmsh : from_gmsh

Les mailleurs de pyrucast couvrent le cas courant ; les géométries difficiles, elles, se délèguent. On définit la géométrie et on maille dans gmsh — c’est son métier —, puis on appelle from_gmsh pour récupérer le résultat, sans passer par un fichier :

import pyrucast

# — la géométrie et le maillage restent l'affaire de gmsh —
gmsh.model.occ.addBox(0, 0, 0, 1, 1, 1)
gmsh.model.occ.synchronize()
gmsh.model.addPhysicalGroup(2, [1], name="encastrement")
gmsh.model.addPhysicalGroup(3, [1], name="piece")
gmsh.model.mesh.generate(3)

# — pyrucast comes and fetches the result, without going through a file —
coords = pyrucast.Coords(dim=3)
regions, _ = pyrucast.mesh.from_gmsh(coords)

piece = regions["piece"]
print(piece.element_types())  # ['TET4']
print(regions["encastrement"].element_types())  # ['TRI3']
print(coords.node_count)  # the model's nodes, shared by both

gmsh.finalize()  # pyrucast owns its data: the mesh outlives it

pyrucast lit gmsh, il ne le pilote pas : aucune fonction pyrucast ne crée de géométrie ni ne lance de maillage. from_gmsh rend un couple (maillages, champs). Les maillages forment le même dict[str, Mesh] que read_gmsh, avec les mêmes règles — un Mesh par groupe physique, une zone par type d’élément, une seule Coords partagée dont la dimension décide combien des trois coordonnées de gmsh sont gardées, et "<ungrouped>" pour le reste.

Les surfaces et les points nommés dans gmsh (addPhysicalGroup(…, name=…)) deviennent donc les clés du dictionnaire, et c’est sur elles qu’on pose ensuite les conditions aux limites. gmsh maille ses entités ponctuelles : un point nommé arrive comme un Mesh POI1.

Les vues gmsh deviennent des champs

Les vues du modèle (post-traitement gmsh, gmsh.view.addModelData) sont lues aussi, sauf si l’on passe views=False :

vue gmshchamp pyrucast
NodeDataNodeField
ElementDataElementField à un point par maille, avec une zone sur chaque groupe dont il définit toutes les mailles
plusieurs pas de tempsEvolution sur les temps de la vue

Une vue scalaire garde son nom comme nom de composante ; une vue à n composantes les nomme nom_0 … nom_{n-1}. Les autres sortes de vues (ElementNodeData, vues « liste ») sont ignorées avec un avertissement.

Ce qui est copié, et ce qui ne l’est pas

La chaîne a trois maillons, dont deux sont gratuits :

mailloncopie ?pourquoi
gmsh → numpynonles tableaux que rend l’API gmsh sont des vues sur la mémoire de gmsh, libérées au ramasse-miettes du tableau
numpy → pyrucastnonlecture par le protocole tampon — d’où le plancher Python 3.11 du projet, PyObject_GetBuffer n’étant entré dans l’API limitée qu’à cette version. Les étiquettes uint64 de gmsh comme int64 de medcoupling sont empruntées telles quelles
construction du maillageoui, une passeune Coords et un sous-maillage possèdent leurs tableaux : les coordonnées sont écrites directement dans la Coords, chaque connectivité est allouée une fois à sa taille et réécrite sur place dans l’ordre pyrucast

Autrement dit : une vue jusqu’à la frontière Rust, puis une seule passe. Rien n’est jamais matérialisé élément par élément côté Python. Une fois l’appel rendu, pyrucast possède ses données — gmsh.finalize() ne les emporte pas.

Un objet sans tampon à prêter — une list, ce que gmsh rend quand numpy n’est pas installé — est lu par la conversion ordinaire. Le résultat est le même, au prix d’une copie de plus.

Restreindre l’import

dim limite l’import à une dimension, tag à une seule entité de cette dimension (from_gmsh(coords, dim=2, tag=1)). La table des nœuds est lue en entier quoi qu’il arrive — une maille de surface s’appuie sur des nœuds classés sur ses courbes de bord — et seuls les nœuds référencés sont matérialisés.

Une nuance à connaître pour le contrôle croisé : gmsh.write() n’écrit par défaut que les éléments portant un groupe physique, alors que from_gmsh voit tout le modèle. Le .msh relu peut donc contenir moins que l’import direct — la différence tient dans "<ungrouped>".

Lire un fichier MED : from_medcoupling

Le format MED est celui de Salome et de code_aster. pyrucast le lit et l’écrit au travers de medcoupling, la bibliothèque MED de Salome (pip install medcoupling). Elle n’est pas une dépendance de pyrucast : import pyrucast ne la charge pas, seul l’appel de from_medcoupling (ou de to_medcoupling) l’importe.

coords = pyrucast.Coords(dim=2)
regions, champs = pyrucast.mesh.from_medcoupling(coords, "plaque.med")
print(sorted(regions))  # ['bas', 'plaque'] — one Mesh per MED group
print(type(champs["T"]).__name__)  # Evolution: two time steps
print(champs["T"].shared_abscissas())  # [0.0, 60.0]
print(type(champs["sxx"]).__name__)  # ElementField, on pyrucast's Gauss points

La source est un chemin vers un .med, un medcoupling.MEDFileData ou un medcoupling.MEDFileUMesh (maillage seul) ; mesh_name choisit un maillage du fichier (par défaut le premier). Le résultat est un couple (maillages, champs) :

  • un Mesh par groupe MED — groupes de mailles de tous les niveaux, et groupes de nœuds sous forme de Mesh POI1 —, tous sur la Coords fournie, les mailles sans groupe sous "<ungrouped>". Les familles MED, qui codent l’appartenance aux groupes, sont traduites une fois par famille et par type, jamais maille par maille ;
  • les champs, sous leur nom MED :
champ MEDchamp pyrucast
ON_NODESNodeField sur les nœuds définis (profil compris)
ON_CELLSElementField à un point par maille
ON_GAUSS_PTElementField sur les points de Gauss de pyrucast — si la règle MED est la même, sinon une erreur explicite
plusieurs pas de tempsEvolution sur les temps du champ

Un champ aux mailles reçoit une zone sur chaque sous-maillage de groupe dont il définit toutes les mailles. Les autres discrétisations (ON_GAUSS_NE…) sont ignorées avec un avertissement.

La numérotation MED des nœuds dans une maille diffère de celle de pyrucast (qui est celle de VTK) pour les volumes : MED parcourt la première face dans l’autre sens. La permutation est une propriété de l’élément (ElementKind::med_permutation) et s’applique en Rust, pendant la passe de construction. Elle est vérifiée contre medcoupling lui-même : pour chacun des quinze types, la maille de référence écrite en MED a son volume positif et ses nœuds milieux au milieu des arêtes que medcoupling en déduit.

medcoupling n’est publié que pour Linux x86_64 et Windows (CPython 3.9 à 3.13) : pas de macOS ni d’ARM. Sa licence est la LGPL, ce qui convient à une dépendance facultative importée à l’exécution.

L’opérateur en dessous : from_arrays

from_gmsh et from_medcoupling sont les seules fonctions de pyrucast.mesh écrites en Python : elles ont besoin d’un interpréteur portant le module gmsh ou medcoupling, ce que Rust ne peut pas avoir. Elles ne font qu’aller chercher des tableaux et les passer à from_arrays, l’import générique que partagent tous les formats — utilisable directement quand on tient déjà les tableaux :

# The same square, but as flat arrays already in memory: the tags of the
# nodes, their coordinates, then one block per element type and group, whose
# connectivity is flattened. This is what `from_gmsh` and `from_medcoupling`
# hand over.
tags = [1, 2, 3, 4]
xyz = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0]
blocs = [
    ("SEG2", [1, 2], ["bottom"]),
    ("TRI3", [1, 2, 3, 1, 3, 4], ["plate"]),
]

coords = pyrucast.Coords(dim=2)
regions, _, _ = pyrucast.mesh.from_arrays(coords, tags, xyz, blocs)
print(regions["plate"].element_types())  # ['TRI3']
print(coords.node_count)  # 4 — a single Coords for both groups

Le format est commun à tous les échanges :

  • nœuds — une étiquette entière par nœud (uint64 ou int64) et 1 à 3 coordonnées par nœud ;
  • blocs — (type, connectivité, étiquettes de mailles, groupes) : un bloc réunit un type d’élément et une combinaison de groupes, soit exactement une famille MED ou une entité gmsh. Les étiquettes de mailles ne servent qu’aux champs aux mailles (une séquence vide sinon, ou un triplet sans elles) ;
  • champs — node_fields=[(composantes, étiquettes de nœuds, valeurs)], cell_fields=[(composantes, étiquettes de mailles, valeurs, disposition)], la disposition valant "cell" ou une liste de règles de Gauss (type, nœuds de référence, points, poids) ;
  • ordre — order="pyrucast", "gmsh" ou "med" : la numérotation des nœuds dans une maille, réalignée en Rust.

from_arrays rend (maillages, champs aux nœuds, champs aux éléments). Le fichier .msh passe par le même moteur : le regroupement, le partage des nœuds et les permutations ne sont écrits qu’une fois, et un test compare maille pour maille ce que le fichier et la mémoire rendent de la même géométrie.

Côté Rust, ops::mesh::from_arrays(coords, node_tags, node_coords, blocks, node_values, cell_values, order) prend des CellBlock, NodeValues et CellValues qui ne font qu’emprunter les tableaux, et rend un Imported. Pour un maillage de 884 000 hexaèdres, la construction prend 79 ms contre 498 ms pour l’ancien import gmsh (cargo bench --bench mesh -- from_arrays) : plus de table de hachage par nœud, plus d’allocation ni de nom de groupe par maille.

Le chemin inverse : to_arrays

pyrucast.export.to_arrays rend la même forme, à partir de maillages et de champs pyrucast — c’est la sortie que partagent l’export VTK, to_gmsh et to_medcoupling :

# The way back: the same flat shape, ready for any other tool.
sortie = pyrucast.export.to_arrays(regions)
print(list(sortie["node_tags"]))  # [1, 2, 3, 4]
print(sortie["blocks"][1][0], list(sortie["blocks"][1][1]))  # TRI3 [1, 2, 3, 1, 3, 4]
# Each array is a read-only pyrucast.Array: numpy.asarray(...) or
# memoryview(...) read it without a copy; tolist() copies it into Python.
tags = sortie["node_tags"]
print(isinstance(tags, pyrucast.Array), tags.format)  # True l — int64
print(tags.tolist())  # [1, 2, 3, 4]
  • les nœuds sont numérotés dans l’ordre de première apparition, à partir de first_tag (1 par défaut, 0 pour medcoupling) ;
  • une maille présente dans plusieurs groupes — ce que produit tout import gmsh ou MED — n’est écrite qu’une fois, dans un bloc qui porte tous ses groupes ;
  • un champ aux nœuds donne une ligne par nœud, 0 là où il ne définit rien ; un champ aux éléments donne la moyenne de ses points de Gauss par maille, ou avec gauss=True ses valeurs brutes et la règle de chaque type ;
  • chaque tableau est un pyrucast.Array, en lecture seule, que numpy.asarray ou memoryview lisent sans copie — sans que numpy soit une dépendance de pyrucast.

Deux traductions servent aux adaptateurs et restent utilisables seules : le type pyrucast d’un code gmsh, et la règle de Gauss de pyrucast exprimée dans l’élément de référence d’un autre format (et l’appariement inverse, qui refuse une règle différente) :

# The translations the exchange adapters rely on.
print(pyrucast.mesh.element_type_from_gmsh(4))  # TET4 — gmsh code 4
# pyrucast's Gauss rule of TRI3, in a reference triangle twice as large:
refs = [0.0, 0.0, 2.0, 0.0, 0.0, 2.0]
xi, w = pyrucast.mesh.gauss_to_external("TRI3", refs, "pyrucast")
print(len(w))  # 3 points, weights four times heavier
# ...and back: which external point is each of pyrucast's points.
print(pyrucast.mesh.match_gauss("TRI3", refs, xi, w, "pyrucast"))  # [0, 1, 2]

Triangulation : briques mathématiques

Ce chapitre rassemble les fondements mathématiques utilisés par triangulate_surface et le module pyrucast::ops::mesh::triangulation. Toutes les formules sont écrites avec la convention de pyrucast : points 2D notés \( P = (x, y) \), points 3D notés \( P = (x, y, z) \), vecteurs en gras, produit scalaire \( \cdot \), produit vectoriel \( \times \).

Aire signée d’un polygone 2D (formule du lacet)

Soit \( P_0, P_1, \dots, P_{n-1} \) les sommets d’un polygone simple fermé (l’arête \( P_{n-1} P_0 \) ferme la boucle implicitement). Son aire signée est

\[ A = \frac{1}{2} \sum_{i=0}^{n-1} \left( x_i\, y_{i+1} - x_{i+1}\, y_i \right) \]

avec la convention d’indices modulo \(n\). Le signe de \(A\) encode l’orientation :

  • \( A > 0 \) : polygone parcouru en sens trigonométrique (CCW) ;
  • \( A < 0 \) : sens horaire (CW) ;
  • \( A \approx 0 \) : polygone dégénéré (sommets colinéaires).

Implémentation : signed_area dans src/ops/mesh/triangulation.rs.

use pyrucast::atoms::Point2;
use pyrucast::ops::mesh::triangulation::signed_area;

#[test]
fn l_aire_signee_donne_le_sens_de_parcours() {
    // Carré unitaire CCW — aire = +1.
    let pts = vec![
        Point2::new(0.0, 0.0),
        Point2::new(1.0, 0.0),
        Point2::new(1.0, 1.0),
        Point2::new(0.0, 1.0),
    ];
    assert!((signed_area(&pts) - 1.0).abs() < 1e-12);

    // The same square CW — area = -1.
    let pts_cw: Vec<_> = pts.iter().cloned().rev().collect();
    assert!((signed_area(&pts_cw) + 1.0).abs() < 1e-12);
}

signed_area n’est pas exposée en Python : elle est utilisée en interne par triangulate_surface. Pour obtenir l’aire d’un maillage depuis Python, calculez-la à partir des coordonnées des triangles.

Ear clipping : usage direct

La fonction ear_clip_2d est utilisable indépendamment de triangulate_surface sur n’importe quel polygone 2D simple :

use pyrucast::ops::mesh::triangulation::ear_clip_2d;

#[test]
fn le_decoupage_par_oreilles_rend_n_moins_2_triangles() {
    // Pentagone CCW quelconque.
    let pts = vec![
        Point2::new(0.0, 0.0),
        Point2::new(2.0, 0.0),
        Point2::new(2.5, 1.5),
        Point2::new(1.0, 2.5),
        Point2::new(-0.5, 1.5),
    ];
    let triangles = ear_clip_2d(&pts).unwrap();
    // n - 2 = 3 triangles, indices into pts.
    assert_eq!(triangles.len(), 3);

    // Checking that a triangle is CCW (signed area > 0).
    for [i, j, k] in &triangles {
        let area = signed_area(&[pts[*i], pts[*j], pts[*k]]);
        assert!(area > 0.0, "triangle non-CCW détecté");
    }
}

Test d’oreille (ear clipping)

Pour un polygone simple CCW à \(n\) sommets, un sommet \(P_i\) est une oreille si :

  1. Convexité locale en \(P_i\) — le triangle \((P_{i-1}, P_i, P_{i+1})\) est CCW :

    \[ (P_i - P_{i-1}) \times (P_{i+1} - P_i) > 0 \]

    (produit en croix 2D \( \mathbf{a} \times \mathbf{b} = a_x b_y - a_y b_x \)).

  2. Cavité vide — aucun autre sommet du polygone n’est dans le triangle fermé \((P_{i-1}, P_i, P_{i+1})\).

L’algorithme retire itérativement une oreille (créant un triangle de sortie et un polygone à \(n-1\) sommets) jusqu’à ne plus avoir que 3 sommets. Au total \(n - 2\) triangles sont produits.

L’orientation est détectée d’abord via \(A\) ; si \(A < 0\), on parcourt les indices à l’envers pour ramener à du CCW.

Plan moyen et base locale : usage direct

use pyrucast::atoms::Point3;
use pyrucast::ops::mesh::triangulation::{in_plane_basis, newell_normal};

#[test]
fn un_contour_3d_planaire_se_ramene_a_un_repere_local() {
    // A triangle in the y = 0 plane (the xz plane).
    let pts = vec![
        Point3::new(0.0, 0.0, 0.0),
        Point3::new(1.0, 0.0, 0.0),
        Point3::new(0.5, 0.0, 1.0),
    ];

    let normal = newell_normal(&pts).unwrap();
    // Normale attendue : (0, -1, 0) ou (0, 1, 0) selon le sens.
    assert!(normal.y.abs() > 0.99);

    let (u, v) = in_plane_basis(normal);
    // u and v are orthogonal to each other and to the normal.
    assert!(u.dot(&v).abs() < 1e-12);
    assert!(u.dot(&normal).abs() < 1e-12);

    // Projecting a point into the local frame (u, v).
    let origin = Point3::new(0.0, 0.0, 0.0);
    let p = Point3::new(0.5, 0.0, 0.5);
    let pu = (p - origin).dot(&u);
    let pv = (p - origin).dot(&v);
    println!("({pu:.3}, {pv:.3})");
}

newell_normal et in_plane_basis ne sont pas exposées en Python. Elles sont utilisées en interne par triangulate_surface pour les configurations 3D.

Plan moyen d’un polygone 3D : méthode de Newell

Soit un polygone à \(n\) sommets \(P_0, \dots, P_{n-1}\) approximativement coplanaires. La normale de Newell est obtenue par somme signée d’arêtes consécutives :

\[ \vec{n} = \sum_{i=0}^{n-1} \begin{pmatrix} (y_i - y_{i+1})(z_i + z_{i+1}) \\ (z_i - z_{i+1})(x_i + x_{i+1}) \\ (x_i - x_{i+1})(y_i + y_{i+1}) \end{pmatrix} \]

Propriétés clefs :

  • chaque composante de \(\vec{n}\) vaut 2 × l’aire signée du polygone projeté sur le plan coordonné correspondant (lacet généralisé en 3D) ;
  • \(\vec{n}\) est invariant par translation des sommets ;
  • la direction de \(\vec{n}/\|\vec{n}\|\) suit la règle de la main droite par rapport au sens de parcours.

Si \(\|\vec{n}\| \approx 0\), le polygone est dégénéré (colinéaire ou auto-recouvrant) — newell_normal renvoie alors None.

Base orthonormée du plan (Gram-Schmidt)

Étant donnée la normale unitaire \(\hat{n}\), on construit une base directe \((\vec{u}, \vec{v}, \hat{n})\) :

  1. On choisit l’axe canonique \(\vec{e}\) le moins aligné avec \(\hat{n}\) (composante absolue minimale).

  2. On orthogonalise par Gram-Schmidt :

    \[ \vec{u}' = \vec{e} - (\vec{e} \cdot \hat{n})\, \hat{n}, \qquad \vec{u} = \frac{\vec{u}'}{\|\vec{u}'\|} \]

  3. On complète :

    \[ \vec{v} = \hat{n} \times \vec{u} \]

Par construction \( \|\vec{u}\| = \|\vec{v}\| = 1 \), \( \vec{u} \cdot \vec{v} = \vec{u} \cdot \hat{n} = \vec{v} \cdot \hat{n} = 0 \), et \( \vec{u} \times \vec{v} = \hat{n} \) (triedre direct).

Projection orthogonale et critère de planéité

Un point 3D \(P\) est projeté dans le plan local d’origine \(O\) (centroïde du contour) par :

\[ p_u = (P - O) \cdot \vec{u}, \qquad p_v = (P - O) \cdot \vec{v} \]

La distance algébrique de \(P\) au plan est

\[ d = (P - O) \cdot \hat{n} \]

Le contour est jugé « plan » si

\[ \max_i |d_i| \le \varepsilon \cdot \mathrm{diag}, \qquad \varepsilon = 10^{-6} \]

où diag est la longueur de la diagonale de la AABB de l’ensemble des sommets. La tolérance relative \(10^{-6}\) tolère le bruit numérique sans laisser passer une vraie courbure 3D.

Pipeline Delaunay et CDT : usage direct en Rust

Les fonctions du module pyrucast::ops::mesh::triangulation sont utilisables indépendamment du système Mesh.

Delaunay pur

use pyrucast::ops::mesh::triangulation::delaunay_2d;

#[test]
fn delaunay_maille_un_nuage_de_points() {
    let pts = vec![
        Point2::new(0.0, 0.0),
        Point2::new(3.0, 0.0),
        Point2::new(3.0, 3.0),
        Point2::new(0.0, 3.0),
        Point2::new(1.5, 1.5), // point intérieur
    ];
    let triangles = delaunay_2d(&pts).unwrap();
    // 4 points = 2 triangles Delaunay ; le 5e point intérieur en ajoute d'autres.
    println!("{} triangles", triangles.len());
    assert!(triangles.len() >= 2);
}

Delaunay contraint (CDT) avec trous

use pyrucast::ops::mesh::triangulation::triangulate_polygon_with_holes;

#[test]
fn un_polygone_troue_se_triangule_directement() {
    // Contour extérieur : carré 4×4.
    let outer = vec![
        Point2::new(0.0, 0.0),
        Point2::new(4.0, 0.0),
        Point2::new(4.0, 4.0),
        Point2::new(0.0, 4.0),
    ];
    // Trou : carré 2×2 centré.
    let hole = vec![
        Point2::new(1.0, 1.0),
        Point2::new(3.0, 1.0),
        Point2::new(3.0, 3.0),
        Point2::new(1.0, 3.0),
    ];
    let triangles = triangulate_polygon_with_holes(&outer, &[hole]).unwrap();
    // Area = 16 - 4 = 12; without Steiner: 6 raw triangles.
    println!("{} triangles", triangles.len());
    assert!(!triangles.is_empty());
}

CDT avec raffinement de Ruppert

use pyrucast::ops::mesh::triangulation::{
    triangulate_polygon_with_holes_refined, RefinementOptions,
};

#[test]
fn le_raffinement_de_ruppert_insere_des_points_de_steiner() {
    let outer = vec![
        Point2::new(0.0, 0.0),
        Point2::new(4.0, 0.0),
        Point2::new(4.0, 4.0),
        Point2::new(0.0, 4.0),
    ];
    let opts = RefinementOptions {
        max_edge_length: Some(1.0),
        min_angle_deg: Some(20.0),
    };
    // The refinement inserts Steiner points: the function therefore returns
    // **the points** (input + Steiner) *and* the triangles indexing them.
    let (points, triangles) = triangulate_polygon_with_holes_refined(&outer, &[], opts).unwrap();
    println!(
        "{} triangles après raffinement, {} points",
        triangles.len(),
        points.len()
    );
    assert!(points.len() > outer.len());
}

Ces fonctions renvoient des indices dans le tableau de points fourni en entrée (plus les Steiner éventuels). Elles ne touchent pas à la Coords — triangulate_surface se charge de la conversion vers les NodeId.

Triangulation de Delaunay : la propriété du cercle vide

Une triangulation \(T\) d’un nuage de points \(\{P_0, \dots, P_{n-1}\}\) est dite de Delaunay si, pour tout triangle \((P_a, P_b, P_c) \in T\), le disque circonscrit ne contient strictement aucun autre point du nuage.

Cette propriété équivaut à maximiser l’angle minimum sur l’ensemble des triangulations possibles — c’est pourquoi Delaunay est la triangulation de référence pour le FEM : elle évite naturellement les triangles très allongés.

Test in-circumcircle

Pour un triangle \((A, B, C)\) orienté CCW, le point \(D\) est strictement dans son disque circonscrit si et seulement si

\[ \det\! \begin{pmatrix} a_x - d_x & a_y - d_y & (a_x - d_x)^2 + (a_y - d_y)^2 \\ b_x - d_x & b_y - d_y & (b_x - d_x)^2 + (b_y - d_y)^2 \\ c_x - d_x & c_y - d_y & (c_x - d_x)^2 + (c_y - d_y)^2 \end{pmatrix} > 0 \]

Si le déterminant vaut zéro, les 4 points sont cocirculaires (cas dégénéré) ; s’il est négatif, \(D\) est strictement à l’extérieur.

C’est ce prédicat (sans normalisation, calculé en f64) que pyrucast utilise dans Bowyer-Watson.

Bowyer-Watson : insertion incrémentale

Pour insérer un nouveau point \(p\) dans une triangulation de Delaunay :

  1. On identifie l’ensemble bad des triangles dont le cercle circonscrit contient \(p\) (déterminant ci-dessus > 0).
  2. Leur union forme un polygone étoilé autour de \(p\) (théorème de Bowyer 1981 et Watson 1981).
  3. On retire ces triangles ; le bord de la cavité est un polygone simple.
  4. On retriangule en éventail depuis \(p\) : pour chaque arête \((u, v)\) de la cavité, on crée le triangle \((u, v, p)\).

L’invariant Delaunay est préservé par construction : les nouveaux triangles ne peuvent pas avoir d’autre point dans leur cercle circonscrit, sinon ce triangle aurait été dans la cavité.

Initialisation : pyrucast utilise un super-triangle englobant largement la AABB du nuage de points, ce qui garantit que tout point inséré tombera dans au moins un bad triangle. Les triangles touchant le super-triangle sont retirés à la fin.

Triangulation contrainte (CDT) : forcer des arêtes

Une CDT étend Delaunay avec des arêtes imposées (typiquement les arêtes du contour à mailler). La propriété du cercle vide n’est plus exigée à travers ces arêtes contraintes.

Pour forcer une arête \((a, b)\) absente :

  1. Identifier tous les triangles strictement traversés par le segment \((a, b)\). On suit une « marche » : on part d’un triangle contenant \(a\) ; on traverse en suivant les arêtes coupées.
  2. Retirer ces triangles ; le bord de la cavité forme deux polygones simples, l’un à gauche, l’autre à droite de l’arête \((a, b)\). On les sépare via le signe du produit en croix.
  3. Retrianguler chaque polygone par ear clipping.

Identification intérieur / extérieur : flood-fill par parité

Théorème de Jordan : tout polygone simple fermé partage le plan en deux composantes connexes — un intérieur borné et un extérieur non borné. Chaque traversée du polygone bascule de l’une à l’autre.

Pour un domaine à trous, on étend par parité :

Nombre de contraintes traversées depuis l’extérieurStatut
0extérieur (hors du contour englobant)
1intérieur du domaine maillé
2dans un trou
3îlot dans un trou
…alterne

Le flood-fill par parité :

  1. Tout triangle adjacent au super-triangle est étiqueté « extérieur ».
  2. On propage en BFS le long des voisins :
    • Si l’arête entre le triangle courant et son voisin est contrainte ⇒ on inverse la couleur.
    • Sinon ⇒ on conserve.
  3. À la fin, on garde uniquement les triangles « intérieurs ».

Cela fonctionne pour n’importe quel nombre de trous emboîtés, sans tester explicitement la containment.

Raffinement de Ruppert : points Steiner

L’algorithme de Ruppert (1995) raffine une CDT pour satisfaire deux critères de qualité utilisateur :

  • une longueur d’arête maximale \(h_{\max}\) ;

  • un angle minimum \(\alpha_{\min}\), équivalent à un critère sur le rapport circumrayon / arête plus courte :

    \[ \frac{r}{L_{\min}} \le \frac{1}{2 \sin \alpha_{\min}} \]

Encroachment

Une arête contrainte \(AB\) est dite encroachée par un point \(p\) si \(p\) est strictement à l’intérieur du disque diamétral de \(AB\) — c’est-à-dire le disque de centre \(M = (A + B)/2\) et de rayon \(\|AB\|/2\). Condition :

\[ \| p - M \|^2 < \frac{\| A - B \|^2}{4} \]

Boucle de Ruppert

boucle:
    Si une arête contrainte AB est encroachée par un sommet :
        couper AB en son milieu M (M devient un Steiner, AB est remplacé par AM + MB).
        continuer.

    Sinon, chercher un triangle "mauvais" :
        - longueur d'arête > h_max, OU
        - rapport r/L_min > 1/(2 sin α_min) (triangle "skinny")
    Si aucun : terminé.

    Sinon, soit C le centre du cercle circonscrit du mauvais triangle.
    Si C encroache une arête contrainte :
        couper cette arête en son milieu (Steiner) — retour au début de boucle.
    Sinon :
        insérer C par Bowyer-Watson contraint.

L’insertion contrainte (Bowyer-Watson modifié) propage la cavité en BFS depuis le triangle contenant \(C\), sans jamais franchir d’arête contrainte. Cela préserve toutes les contraintes initiales et les nouvelles (milieux d’arêtes).

Contour figé dans pyrucast. L’algorithme de Ruppert ci-dessus coupe une arête de bord encroachée en son milieu (les deux étapes « couper AB en son milieu »). triangulate_surface ne le fait pas : le contour d’entrée doit être conservé à l’identique (mêmes NodeId, mêmes positions). Un point ou un circoncentre qui encroache une arête contrainte est donc simplement abandonné au lieu de la bissecter. Le raffinement ne pose que des Steiner intérieurs ; un bord finement maillé se prépare en amont (mesher.line/arc/circle).

Convergence

La preuve de terminaison de Ruppert (renforcée par Shewchuk en 1996) tient pour

\[ \alpha_{\min} \le 20.7^{\circ} \approx \arcsin\!\frac{1}{2\sqrt{2}} \]

Au-delà de cette borne, certaines configurations très étirées peuvent conduire à des cycles d’insertion (chaque point inséré crée un nouveau triangle « skinny »). pyrucast plafonne le nombre total d’insertions à \( 50 \cdot n_\text{contour} + 1000 \) ; si la limite est atteinte, la fonction renvoie une erreur explicite plutôt que de boucler indéfiniment.

Garanties asymptotiques

Pour des entrées « raisonnables » (arêtes du contour formant des angles entre elles ≥ 60°), le maillage final a :

  • toutes les arêtes \(\le h_{\max}\) ;
  • tous les angles \(\ge \alpha_{\min}\) ;
  • une taille proche du minimum (constante de l’ordre du logarithme du ratio AABB / arête la plus courte de l’entrée).

Références

  • Bowyer, A. Computing Dirichlet tessellations. Computer Journal 24(2), 1981.
  • Watson, D. F. Computing the n-dimensional Delaunay tessellation with application to Voronoi polytopes. Computer Journal 24(2), 1981.
  • Newell, M. E. The utilization of procedure models in digital image synthesis. PhD thesis, Univ. of Utah, 1975.
  • Ruppert, J. A Delaunay refinement algorithm for quality 2-dimensional mesh generation. J. Algorithms 18(3), 1995.
  • Shewchuk, J. R. Delaunay refinement mesh generation. PhD thesis, CMU, 1997.
  • Shewchuk, J. R. Triangle: Engineering a 2D quality mesh generator and Delaunay triangulator. WACG, 1996.

Opérateurs de construction

Les champs matériau prêts pour l’assemblage vivent dans ops::element_field, avec les autres producteurs de champs aux points de Gauss : le module porte le nom du conteneur qu’il produit. L’ancien module ops::build, dont le nom ne désignait aucune famille, a disparu avec le redécoupage.

Champs matériau

Un assemblage a besoin d’un ElementField portant les propriétés matériau (conductivité k, module E, Poisson nu…) aux points de Gauss. Plutôt que de construire ce champ à la main, ces opérateurs le fabriquent en appariant les zones aux sous-modèles qui consomment du matériau, et en ignorant ceux qui n’en ont pas (les contraintes comme Dirichlet).

PythonEffet
material_field(model, [(nom, valeur), …])un ElementField uniforme : une zone par sous-modèle consommateur de matériau, chaque composante demandée mise à la valeur donnée
material_field_per_sub_model(model, [[(nom, valeur), …], …])un ElementField uniforme par zone : une liste de paires par sous-modèle du modèle, dans l’ordre, une liste vide pour un sous-modèle sans matériau, comme une contrainte (matériaux différents par zone)
sub_material_field(sub_model, [(nom, valeur), …])une seule zone (SubElementField) pour un sous-modèle donné
import pyrucast

# Thermique : conductivité uniforme.
materials = pyrucast.element_field.material_field(thermique, [("k", 1.0)])

# Elasticity: two properties. Every physics declares the components it
# requires: `material_field` refuses the missing ones.
materials = pyrucast.element_field.material_field(
    elastique, [("E", 210e9), ("nu", 0.3)]
)

Le champ produit est ensuite passé tel quel à stiffness(model, materials) et à integrate_behavior. Comme l’assembleur sélectionne, pour chaque sous-modèle, la zone dont le SubFiniteElementSpace correspond au sien, un champ couvrant seulement certains sous-espaces reste valide tant que chaque sous-modèle gourmand en matériau trouve sa zone.

Une fois construit, le champ matériau est un ElementField ordinaire : on peut le faire varier dans l’espace (écriture par zone, par cellule) ou le mettre à l’échelle (arithmétique de champ) — voir Champ aux points de Gauss.

Opérateurs géométriques

Le module ops::geom est réservé aux mesures géométriques : tout ce qui prend un Mesh / SubMesh (et éventuellement un champ de coordonnées) et renvoie un scalaire ou une grandeur géométrique dérivée.

Localisation et projection

Deux primitives d’appariement géométrique, aujourd’hui internes (API Rust, sous les contraintes qui les consomment) :

  • locate_points(host, points, tol) — mapping iso-paramétrique inverse : pour chaque point, la maille hôte qui le contient et ses coordonnées de référence ξ (Newton sur x − Σ Nᵢ(ξ)·Xᵢ, test d’appartenance au domaine de référence), d’où les poids Nᵢ(ξ) et les nœuds de la maille. C’est la brique du baignage (u(p) = Σᵢ Nᵢ·u(hôteᵢ)).
  • project_points(surface, points) — projection au point le plus proche sur un maillage surfacique (facettes de dimension sdim−1 : SEG2/SEG3 en 2D, TRI*/QUA* en 3D). Pour chaque point, la facette la plus proche, ξ clampé au domaine de référence (bords/coins gérés par le clamp), poids Nᵢ(ξ), normale orientée et jeu signé (x − p)·n. C’est la brique du contact.

Nœud le plus proche — une méthode, plus un opérateur

mesh.nearest_node(point) rend le nœud du maillage le plus proche (distance euclidienne) de point. Question purement nodale, complémentaire de locate_points (qui, elle, rend la maille contenant le point) : seuls les nœuds effectivement référencés par une maille sont candidats, et les ex-æquo sont départagés par le plus petit identifiant, donc le résultat est déterministe. Pratique pour cibler un nœud où poser une condition aux limites ou lire un résultat quand on connaît sa position approximative mais pas son id.

Elle n’est plus un opérateur : elle a quitté ops::geom pour devenir une méthode de Mesh, des deux côtés. C’est ce que dit la règle — un seul conteneur, un point pour tout autre argument, et une vue dérivée bon marché : le cas typique de la méthode, pas de la fonction libre. Elle vivait dans ops::geom par voisinage thématique avec locate_points, et cela créait une asymétrie que la convention interdit : fonction libre côté Rust, méthode côté Python.

C’est aussi la requête « un seul nœud » de la famille de sélection par région géométrique — points_in_sphere, points_on_plane, points_in_cylinder, points_on_cone, points_on_torus… — documentée avec les opérateurs de maillage. Ces opérateurs-là rendent toujours un maillage POI1 ; le point le plus proche étant unique, c’est un Node, et il ne rentre donc pas dans la famille.

Sont encore prévus, au fil des besoins : boîtes englobantes (AABB), centroïdes, aires/volumes, métriques de qualité d’élément. Les briques de Jacobien existantes vivent aujourd’hui sur le SubFiniteElementSpace (jacobian, det_jacobian, dn_dx).

Opérateurs sur les champs

Les modules ops::node_field, ops::element_field, ops::coords, ops::measure et ops::field dérivent et transforment les champs — chacun nommé d’après ce qu’il produit : coordonnées, restriction, projection sur un autre maillage, fusion, et dérivations géométriques vers les points de Gauss. Ces opérateurs croisent des conteneurs (maillage + champ, espace EF

  • champ) — ce sont donc des fonctions libres. L’arithmétique scalaire et par composante, elle, reste sur les types de champ (cf. Champ).

Les autres thèmes d’opérateurs ont leur propre page : construction du matériau (Construction), Assemblage (dont le chargement réparti flux), Comportement, Solveur.

Coordonnées et déplacement

PythonEffet
positions(mesh, components=None)un NodeField portant les coordonnées des nœuds ("X", "Y", "Z"), une zone par sous-maillage. None ⇒ tous les axes présents dans la dimension du Coords.
coords.set(field, components=None)écrit les coordonnées du Coords actif depuis un champ "X"/"Y"/"Z".
displace(field, components=None)ajoute un champ de déplacement aux coordonnées (chaque nœud distinct traité une seule fois). On applique ce que porte le champ, et rien d’autre : un nœud qui ne porte aucune des composantes, comme un nœud multiplicateur d’une solution, ne bouge pas, et une composante absente vaut 0.
coords.displace_from(field, reference, target, components=None)pose la configuration target en X + u, en absolu à partir de la configuration reference : rappelée, elle repart de X, rien ne s’accumule. La configuration active ne change pas (Cast3M FORM). On applique ce que porte le champ, et rien d’autre : un nœud qui ne porte aucune des composantes reste à sa position de référence, une composante absente vaut 0, et la solution d’un solve se donne telle quelle.
Coords.in_config(config) (méthode)with coords.in_config(i): le bloc calcule sur la configuration config ; celle d’avant redevient active à la sortie, même après une exception. Voir Coords.

positions est le pont géométrie → champ : on en tire un NodeField qu’on peut tracer, dériver, ou réinjecter après calcul (displace pour passer à la configuration déformée). En grandes transformations, la configuration courante vit dans un jeu à part du même Coords, posé par displace_from et rendu actif le temps d’un calcul par in_config : voir Grandes transformations.

Restriction, fusion, consolidation

PythonEffet
restrict(field, mesh)restreint un NodeField aux nœuds de mesh (une zone par sous-maillage cible). Le support est le nuage POI1 canonique du sous-maillage (to_poi1, matérialisé une fois et mis en cache) : deux restrictions sur le même mesh partagent le support ⇒ restrict(a,mesh) - restrict(b,mesh) se soustrait directement, et s’aligne avec K·restrict(f,mesh) / solve(K,f). Pour les ops élément (gradient, integral, …), repasser mesh à côté. 0.0 pour les nœuds non couverts ; nœuds hors de mesh abandonnés. Erreur si mesh n’est pas sur le même Coords.
restrict_like(field, target)reprojette field sur le support et les composantes de target, zone par zone (mêmes slots que target) ⇒ le résultat se combine directement avec target par les opérateurs + - * /. Nœuds/composantes de field absents de target abandonnés ; 0.0 si non couverts. Typiquement pour replier un incrément de solve (qui porte aussi les multiplicateurs) dans une solution courante. Erreur si Coords différents.
merge(a, b)union structurelle de deux NodeField, consolidée — c’est l’alias nommé de a | b.
node_field.consolidate(field)fusionne les zones de même support (handle identique) en vérifiant la cohérence des valeurs partagées.
element_field.consolidate(field)fusionne les zones d’une même FiniteElementSpace (union des composantes) — le pendant de `

La consolidation d’un NodeField est exactement la finalisation de l’union | : après déduplication par handle, les zones définies sur le même SubMesh deviennent une seule zone portant l’union de leurs composantes — une composante définie par plusieurs zones doit y avoir la même valeur partout (sinon erreur). Une vérification inter-supports finale impose qu’un nœud partagé par des zones de supports différents s’accorde sur toute composante commune. Le même consolidate accepte un ElementField (opération element_field.consolidate) : les sous-champs d’une même FiniteElementSpace fusionnent en une zone portant l’union de leurs composantes — utile pour réunir des zones matériau bâties par physique sur une fespace partagée (k thermique + E/nu/alpha mécanique) en un champ matériau unique lu par chaque physique.

Projection sur un autre maillage

project(field, mesh, target, tol=1e-6) (aussi field.project(mesh, target)) porte un champ nodal aux nœuds de target en l’interpolant par les mailles de mesh : un nœud de target situé dans une maille de mesh reçoit Σ Nᵢ(ξ) f(nœudᵢ), les fonctions de forme de cette maille en ses coordonnées de référence (une maille quadratique interpole quadratiquement), pour toutes les composantes. C’est le PROI de Cast3M sur une géométrie. Un champ nodal ne porte pas de mailles, d’où l’argument mesh, qui doit être sur le même Coords que le champ ; target peut vivre dans un autre Coords, pour porter un champ sur une pièce remaillée.

Un nœud de target hors de mesh — au-delà de tol hors de l’élément de référence de chaque maille, un jeu sans unité — reçoit 0, comme un champ nodal lit un nœud qu’il ne définit pas : on projette ainsi un patch sur un maillage plus grand. Le résultat a une zone par sous-maillage de target, comme positions. Les nœuds de target sont localisés une seule fois.

# La température de la plaque portée aux nœuds d'un autre maillage — ici d'un
# autre Coords, comme une pièce remaillée — par les fonctions de forme des
# mailles de la plaque. Un nœud hors de la plaque reçoit 0 : on projette aussi
# un patch sur un maillage plus grand.
autre = pyrucast.Coords(2)
cibles = [autre.add_node(p) for p in ([0.5, 0.5], [1.5, 0.25], [3.0, 0.5])]
nuage = pyrucast.mesh.poi1_from_nodes(cibles)
portee = pyrucast.node_field.project(temperature, mesh, nuage)  # tol=1e-6
# La même, en méthode : temperature.project(mesh, nuage)

Un champ aux points de Gauss se projette aussi. Avec un ElementField, chaque nœud de target reçoit la valeur que donnent les seuls points de Gauss de la maille qui le contient : leur ajustement aux moindres carrés sur cette maille — celui-là même qui colore la maille sur un tracé —, évalué au point. Rien n’est moyenné entre mailles : deux voisines peuvent donner deux valeurs différentes sur leur face commune, et une suite de points qui passe de l’une à l’autre montre ce saut. Un point posé exactement sur une face partagée est lu dans l’une des deux ; pour suivre un champ le long d’une ligne, on place les points entre les nœuds. mesh est alors le maillage du champ : chacun de ses sous-maillages doit porter une zone du champ.

# A field at the Gauss points read at points: each point takes the value its
# own cell's Gauss points give there — nothing averaged with the neighbour.
de_part_et_d_autre = [autre.add_node(p) for p in ([0.9, 0.5], [1.1, 0.5])]
deux_points = pyrucast.mesh.poi1_from_nodes(de_part_et_d_autre)
lue = pyrucast.node_field.project(par_maille, mesh, deux_points)

Les valeurs relevées sur un nuage de points se tracent ensuite le long de la ligne qu’ils forment : voir Le long d’une ligne.

Bande de valeurs (ge / gt / le / lt)

select et mask partagent la même bande de valeurs, fixée par quatre bornes de comparaison qui reprennent une pour une les opérateurs Python :

ArgumentTestOpérateur
gev ≥ ge>=
gtv > gt>
lev ≤ le<=
ltv < lt<

On donne au plus une borne basse (ge ou gt) et au plus une borne haute (le ou lt), et au moins une borne en tout ; une borne absente laisse ce côté ouvert. Erreur si aucune borne, ou si la borne basse dépasse la haute.

Sélection par valeur

select extrait, zone par zone, la partie du support d’un champ dont les valeurs tombent dans la bande. C’est un filtre par valeur qui renvoie un Mesh — un sous-maillage par zone traitée (les zones restent séparées, rien n’est moyenné ni fusionné).

PythonEffet
select(field, ge=None, gt=None, le=None, lt=None, components=None)sous-ensemble du support du champ respectant la bande, une zone à la fois.
  • Type de champ. Sur un NodeField / SubNodeField, on sélectionne les nœuds : chaque zone donne un sous-maillage POI1 des nœuds retenus. Sur un ElementField / SubElementField, on sélectionne les cellules : chaque zone donne un sous-maillage de son propre type d’élément, et une cellule n’est retenue que si tous ses points de Gauss passent (la bande doit tenir tout le long de la cellule).
  • Composantes. components=None teste toutes les composantes de chaque zone. Une liste components ne teste que ces composantes, et seulement sur les zones qui les portent toutes — une zone à laquelle il manque une composante demandée est ignorée (aucun sous-maillage produit).
  • Combinaison (ET). Quand plusieurs composantes sont testées, elles sont combinées en ET : un nœud / une cellule n’est retenu que si chaque composante testée est dans la bande.
# Nodes whose temperature lies between 20 and 80 °C (inclusive bounds).
chauds = pyrucast.mesh.select(temperature, ge=20.0, le=80.0)

# Cellules dont la contrainte de von Mises dépasse un seuil (borne basse seule).
critiques = pyrucast.mesh.select(sigma, ge=250e6, components=["vm"])

Masque par valeur

mask garde la structure exacte du champ (mêmes zones, même support, mêmes composantes) et se contente de réécrire les valeurs : 1.0 là où la bande tient, 0.0 sinon — composante par composante (le MASQUE de Cast3M). Le résultat est donc du même type que l’entrée et se multiplie terme à terme avec elle. Un NodeField est masqué par nœud, un ElementField par point de Gauss.

PythonEffet
mask(field, ge=None, gt=None, le=None, lt=None, components=None)champ 0/1 de même structure que l’entrée.
  • Pas de ET entre composantes (contrairement à select) : chaque valeur est testée pour elle-même.
  • Composantes. components=None teste toutes les composantes. Une liste components ne teste que celles-ci ; les autres restent à 1.0 (neutre pour le produit), et une zone à laquelle il manque une composante demandée reste tout à 1.0.
# Resets a field's negative values to zero, component by component.
positif = champ * champ.mask(ge=0.0)

# Sugar: comparisons build a mask directly.
positif = champ * (champ >= 0.0)  # the same thing
chauds = temperature > 80.0  # NodeField 0/1

Les opérateurs >=, >, <=, < sur un champ (NodeField, SubNodeField, ElementField, SubElementField) renvoient le masque correspondant contre le scalaire de droite. == / != gardent leur sens Python habituel (identité).

Exemple complet et exécutable : examples/field_mask.py (lancer avec python examples/field_mask.py après maturin develop).

Extraction et renommage de composantes (EXCO)

Deux opérateurs travaillent sur le jeu de composantes d’un champ, sans toucher au support ni aux valeurs — l’équivalent de EXCO de Cast3M. Tous deux acceptent les quatre saveurs (NodeField, SubNodeField, ElementField, SubElementField) et renvoient la même saveur.

PythonEffet
filter_components(field, components)ne garde que les composantes nommées, zone par zone. components est un nom (str) ou une liste de noms — typiquement le résultat de model.primal_vars().
rename_component(field, old, new)renomme la composante old en new (métadonnée seule, aucune valeur déplacée).

filter_components traite chaque zone indépendamment :

  • une zone ne portant aucune des composantes demandées est abandonnée ;
  • une zone ne portant que des composantes demandées (rien à retirer) voit son sous-champ partagé tel quel (handle copié, pas de duplication) ;
  • une zone mixte est reconstruite sur le même support avec les seules composantes demandées, dans son propre ordre.

components peut être un sur-ensemble des composantes du champ (les noms absents sont ignorés) : passer model.primal_vars() à un résultat de solve pour en retirer les inconnues duales (multiplicateurs de Lagrange) est l’usage visé. Erreur si aucune zone ne porte l’une des composantes demandées.

rename_component laisse inchangée (handle partagé) toute zone ne portant pas old. Erreur si aucune zone ne porte old, ou si une zone concernée a déjà une composante nommée new.

# Removes the Lagrange multipliers from a solve result.
u = solution.filter_components(model.primal_vars())

# Renames a component before exporting.
export = u.rename_component("u_x", "DX")

Sucre d’indexation (façon pandas/numpy) : sur les quatre saveurs (NodeField, SubNodeField, ElementField, SubElementField), une clé chaîne ou liste de chaînes appelle filter_components et renvoie la même saveur. Les autres clés gardent leur sens : int/slice → accès aux zones sur un agrégat ; le tuple d’accès à une valeur sur un sous-champ (sub[node, "UX"], sub[cell, gauss, "E"]) est inchangé.

ux = champ["u_x"]  # == filter_components(champ, "u_x")
depl = champ[["u_x", "u_y"]]  # == filter_components(champ, ["u_x", "u_y"])
zone = champ[0]  # inchangé : la zone (SubNodeField)
val = champ[0][node, "u_x"]  # unchanged: the value at the node

L’accesseur champ.components() (présent sur les quatre saveurs) donne la liste des composantes, d’où l’idiome « reprojeter u1 sur les composantes de u2 » :

u = u1[u2.components()]  # u1 cut down to u2's set of components

Côté Rust, filter_components / select_components acceptent indifféremment un &str, un tableau ["u_x", "u_y"] ou un Vec<String> (trait IntoComponentNames) — donc field.filter_components(model.primal_vars()) passe directement.

Dérivation géométrique (vers les points de Gauss)

Ces opérateurs ne dépendent que de l’espace EF et du champ — aucune physique. Ils produisent l’ElementField que le comportement (integrate_behavior) consomme ensuite ; au niveau d’un modèle, strain choisit pour chaque loi celui qui la nourrit. Ils partagent tous le même moteur parallèle : le driver nodal_pointwise (déterministe bit-à-bit, cf. Parallélisme), pendant nodal de element_pointwise.

interp_to_gauss(field, fespace) → ElementField

Interpole un champ nodal vers les points de Gauss (valeurs, pas dérivées) :

\[ f(\xi_g) = \sum_i f_i\, N_i(\xi_g). \]

Le résultat porte les mêmes composantes que l’entrée, une valeur par (cellule, point de Gauss). C’est le pendant « valeurs » de gradient (direction nœuds → Gauss du CHAN de Cast3M) : typiquement pour porter une température nodale aux points de Gauss avant thermal_strain.

gradient(field, fespace) → ElementField

Gradient d’un champ nodal aux points de Gauss, cellule par cellule :

\[ \nabla f = \sum_i f_i\, \nabla N_i \quad \text{évalué en chaque } \xi_g. \]

Une composante de sortie <comp>_<axe> par couple (composante d’entrée, axe).

deformation(u, fespace, measure="linear") → ElementField

Déformation d’un champ de déplacement aux points de Gauss, dans la mesure choisie. u doit porter exactement space_dim composantes (déplacement selon x, y, z). On note \(H = \nabla u\) et \(F = I + H\) :

measureformulecomposantes
"linear" (défaut)\(\varepsilon = \tfrac12(H + H^\top)\)eps_xx, eps_xy, …
"gradient" ("F")\(F = I + H\)toutes les F_ij, ligne par ligne
"green_lagrange"\(E = \tfrac12(F^\top F - I)\)E_xx, E_xy, …
"right_cauchy_green" ("C")\(C = F^\top F\)C_xx, …
"left_cauchy_green" ("b")\(b = F F^\top\)b_xx, …
"hencky" ("log")\(\tfrac12 \ln C\)H_xx, …
"euler_almansi" ("almansi")\(e = \tfrac12(I - b^{-1})\)e_xx, …
"jacobian" ("J")\(J = \det F\), le rapport des volumesJ seul

Chaque mesure a son préfixe : on ne peut pas en lire une à la place d’une autre. Une mesure symétrique produit une composante <préfixe>_<ai><aj> par entrée indépendante i ≤ j. Le gradient F, qui n’est pas symétrique, les produit toutes (9 en 3D, 4 en 2D). En 2D, la direction hors plan n’est pas déformée (\(F_{zz} = 1\)) et ses entrées ne sont pas produites.

La mesure linéarisée est en convention tenseur (eps_xy = ½(∂u_x/∂y + ∂u_y/∂x), pas le cisaillement ingénieur γ). C’est l’entrée du comportement de l’élasticité. Les mesures finies valent zéro (ou \(I\) pour C et b) sous une rotation rigide finie, contrairement à eps.

Sur un sous-espace axisymétrique, une composante <préfixe>_zz est ajoutée. C’est l’entrée orthoradiale, construite sur \(H_{\theta\theta} = u_r / r\), que le gradient méridien ne peut pas exprimer : eps_zz = u_r / r, F_zz = 1 + u_r / r… (cf. Axisymétrie).

Une transformation dégénérée (\(\det F = 0\)) donne des valeurs non finies pour Hencky et Almansi : rien n’est vérifié au point de Gauss.

"jacobian" rend le seul scalaire J, entrée orthoradiale comprise sur un sous-espace axisymétrique. Une maille retournée a J ≤ 0 en un de ses points : c’est ce que le pas à pas vérifie à chaque itéré en grands déplacements. Aucune autre mesure ne le voit : C = FᵀF reste défini positif quand F change de signe, si bien que Green–Lagrange et Hencky se calculent sans broncher sur une maille retournée.

# Linearized strain, the default: eps_xx, eps_xy, eps_yy.
eps = pyrucast.element_field.deformation(u, fes)
# Finite measures share the call; each has its own prefix.
E = pyrucast.element_field.deformation(u, fes, "green_lagrange")  # E_xx…
F = pyrucast.element_field.deformation(u, fes, "F")  # F_xx, F_xy, F_yx, F_yy
H = pyrucast.element_field.deformation(u, fes, "hencky")  # H_xx…

thermal_strain(temperature, materials, fespace, t_ref, measure="linear") → ElementField

Déformation thermique de libre dilatation (Cast3M EPTH), pour la thermomécanique non couplée :

\[ \varepsilon_{th} = \alpha\,(T - T_{ref})\,\big[\,1,1,(1),0,0,0\,\big]. \]

temperature est un champ par éléments portant "T" (p. ex. produit par interp_to_gauss) ; alpha est lu dans le champ matériau, où il voyage comme composante facultative de l’élasticité (à côté de E/nu, cf. Élasticité). La sortie a exactement la même disposition que deformation (composantes normales à α·ΔT, cisaillements nuls — y compris l’orthoradiale eps_zz en axisymétrique, un solide de révolution se dilatant aussi circonférentiellement), si bien que deformation(u, fespace) - thermal_strain(...) donne la déformation mécanique ε(u) − ε_th. Aucun couplage n’est fait ici : l’utilisateur compose la charge thermique et la contrainte réelle σ = D:(ε − ε_th) à partir des briques (integrate_behavior, internal_forces) — cf. l’exemple thermomécanique.

measure est la mesure que lit la loi : la même dilatation y est donnée exactement, ½((1 + αΔT)² − 1) en Green–Lagrange (E_*), ln(1 + αΔT) en Hencky (H_*) — cf. Grandes transformations.

elastic_stress(model, strain, materials) / elastic_strain(model, stress, materials) → ElementField

L’opérateur élastique d’un modèle aux points de Gauss, dans les deux sens (Cast3M ELAS) :

\[ \sigma = \mathbb D : \varepsilon \qquad\text{et}\qquad \varepsilon = \mathbb D^{-1} : \sigma . \]

D est l’opérateur avec lequel chaque loi prédit — pas la réponse de la loi elle-même : la matrice de Hooke d’une élasticité dans sa symétrie (isotrope, orthotrope, anisotrope), le D isotrope du prédicteur élastique d’une plasticité, le D non endommagé que dégrade une loi d’endommagement. Le matériau n’a besoin que des constantes élastiques (E, nu en isotrope) : le sigma_y d’une plasticité n’y joue aucun rôle. C’est ce dont un solveur incrémental a besoin pour transformer un incrément de déformation imposé (thermique, donné) en la contrainte qu’il causerait si rien ne plastifiait — la pseudo-force F* = ∫ Bᵀ D Δε* — ou pour ramener une contrainte à la déformation élastique qui la porte sous d’autres modules ((D⁻¹ₙ₊₁ − D⁻¹ₙ) σₙ quand le matériau varie avec la température).

L’entrée et la sortie en déformation sont en convention tensorielle, disposition de deformation et thermal_strain ; en contraintes planes, elastic_strain n’écrit pas eps_zz, que la loi plane ne lit pas. Une zone par sous-modèle continu ; un sous-modèle sans opérateur élastique (conduction, barre, poutre, contrainte) n’en produit pas. elastic_strain échoue là où D est singulier (un module nul).

Deux fonctions plutôt qu’une qui lirait le sens sur les noms des composantes : les deux sens ne font pas la même opération, et l’état que rend integrate_behavior porte à la fois des sigma_* et des eps_*.

# The stress an imposed strain would cause if nothing yielded: D:ε.
sigma = pyrucast.element_field.elastic_stress(plasticite, dilatation, materiaux)
# And back: the elastic strain carrying a stress, D⁻¹:σ.
retour = pyrucast.element_field.elastic_strain(plasticite, sigma, materiaux)

von_mises(model, stress, coefficients=[0.5, 0.5, 0.5, 1.5, 1.5, 1.5]) → ElementField

La contrainte équivalente aux points de Gauss : von Mises par défaut, la forme anisotrope de Hill avec ses six coefficients [F, G, H, L, M, N] :

\[ \sigma_{eq}^2 = F(\sigma_{yy}-\sigma_{zz})^2 + G(\sigma_{zz}-\sigma_{xx})^2 + H(\sigma_{xx}-\sigma_{yy})^2 + 2L\,\sigma_{yz}^2 + 2M\,\sigma_{xz}^2 + 2N\,\sigma_{xy}^2 \]

Avec \( F = G = H = 1/2 \) et \( L = M = N = 3/2 \), c’est von Mises, \( \sqrt{3 J_2} \) — la valeur par défaut. stress porte les contraintes qu’écrivent les lois (sigma_xx, … : la sortie d’elastic_stress ou l’état d’integrate_behavior conviennent, quoi qu’ils portent d’autre). Le modèle dit lesquelles lire, car elles dépendent de la cinématique :

cinématiquecomposantes luesprises nulles
full_3dles six sigma_*—
plane_stresssigma_xx, sigma_yy, sigma_xyzz, yz, xz
plane_strainsigma_xx, sigma_yy, sigma_zz, sigma_xyyz, xz
axisymmetricsigma_xx, sigma_yy, sigma_zz (orthoradiale), sigma_xyyz, xz

Le résultat a une zone par sous-modèle continu, sur ses points de Gauss, et une seule composante, von_mises. Une erreur nomme ce qui manque : des coefficients qui laisseraient la forme devenir négative (chacun de L, M, N, F + G, G + H, H + F et FG + GH + HF doit être ≥ 0), ou une contrainte sans sigma_zz en déformations planes — les lois plastiques le portent, une contrainte élastique non. Elle n’existe pas en méthode : elle ne s’applique qu’à un champ de contraintes.

# von Mises at each Gauss point, from the stresses the law writes; the model
# says which ones to read (here a plane stress: σzz = 0).
vm = pyrucast.element_field.von_mises(plasticite, contraintes)
# Hill's anisotropic form: the coefficients [F, G, H, L, M, N].
hill = pyrucast.element_field.von_mises(
    plasticite, contraintes, [0.6, 0.4, 0.5, 1.5, 1.5, 2.0]
)

stress_measure(stress, gradient, target) → ElementField

Une contrainte convertie, point par point, d’une mesure à l’autre, avec le gradient de la transformation F (deformation(u, fespace, "gradient")) : Cauchy sigma_*, Kirchhoff tau_*, Piola–Kirchhoff 1 P_* (non symétrique) et 2 S_*, duale de Hencky T_*. La mesure d’entrée se reconnaît à ses noms, une par zone ; target nomme celle qu’on veut ("cauchy", "kirchhoff", "pk1", "pk2", "hencky"). C’est ainsi que la contrainte lagrangienne d’une loi en grandes transformations devient la P que lisent les forces internes, ou la Cauchy des résultats — la moitié « contraintes » de Cast3M CONF. Une barre se reconnaît à son effort de Piola–Kirchhoff 2 axial S_n : "pk1" en fait le vecteur effort nominal P_x, P_y(, P_z) = S_n F t₀, "cauchy" l’effort vrai n = λ S_n (t₀ sa direction sur la configuration active, la référence, comme F). Formules et exemples : Grandes transformations.

rotate_frame(field, gradient) → ElementField

Un champ de matériau dont les axes (V1X, V1Y en 2D, V1X…V2Z en 3D) tournent avec la pièce, point par point, de la rotation R de la décomposition polaire F = R U : v = R v₀, d’où k = R k₀ Rᵀ. F est lu dans gradient, que produit deformation(u, fespace, "gradient") sur la référence. Les autres composantes sont recopiées telles quelles : la matière est la même, seuls ses axes suivent la rotation. Une zone sans axes (matériau isotrope) revient partagée, sans copie ; une zone qui n’en porte qu’une partie est une erreur. C’est ce que lit une conduction ou une diffusion calculée sur la pièce déformée. Pas de méthode : elle exige des axes et un gradient nommés. Exemple : Grandes transformations.

divergence(field) → NodeField

Divergence faible (consistante) d’un champ vectoriel par éléments — l’adjoint de gradient :

\[ d_i = \int_\Omega \nabla N_i \cdot F\, d\Omega \approx \sum_{\text{cell}} \sum_g (\nabla N_i \cdot F)\big|_g\, |J|_g\, w_g, \]

accumulé par nœud. C’est l’opérateur Bᵀ, transposé du gradient : il vérifie ⟨∇f, F⟩ = ⟨f, div F⟩. Un tenseur se lit à ses noms : symétrique (A_xx, A_xy, A_yy), ou plein s’il porte aussi A_yx — comme la contrainte nominale P des grandes transformations. Le champ d’entrée doit porter exactement space_dim composantes (F_x, F_y, F_z) ; la sortie porte une composante "div", et un nœud partagé par plusieurs sous-espaces reçoit la somme de leurs contributions. Ce sont des quantités intégrées, pas les valeurs ponctuelles de ∇·F (pas de projection L²).

cell_integral(field, target) → ElementField et sum_to_nodes(field) → NodeField

Deux briques par maille (Cast3M INTG 'ELEM' et CHAN 'CHPO' 'SOMM') :

  • element_field.cell_integral intègre chaque composante maille par maille, avec la quadrature du champ d’entrée : v_e = Σ_g f(g)·|J|_g·w_g (le 2πr inclus en axisymétrique). Le résultat vit sur target, un espace à un point par maille du même maillage — FiniteElementSpace(mesh, quadrature="REDUCED"), un seul point au barycentre, le support 'GRAVITE' de Cast3M ;
  • node_field.sum_to_nodes rassemble un tel champ aux nœuds par somme : chaque nœud reçoit Σ_{e ∋ i} v_e, la somme des valeurs des mailles qui le touchent, zones confondues. Ce n’est pas un lissage : ni moyenne ni poids.

Intégrer 1 donne la mesure de chaque maille ; la sommer aux nœuds donne le volume que représente chaque nœud — le poids d’un solveur incrémental pour mettre ses résidus à une échelle comparable (l’accélération d’Anderson pondère par (Σ_e |Ω_e|)^{-1/2}).

# One point per cell, at its centroid: where per-cell values live.
centres = pyrucast.FiniteElementSpace(mesh, quadrature="REDUCED")
aires = pyrucast.element_field.cell_integral(un, centres)  # ∫ 1 = the cell's area
# Each node receives the sum over the cells around it.
volumes = pyrucast.node_field.sum_to_nodes(aires)

beam_deformation(field, fespace, material) → ElementField

Déformations généralisées d’une poutre — Bernoulli comme Timoshenko — à partir d’un champ de déplacements et de rotations. Un opérateur pour les trois configurations, lues sur la dimension du maillage :

CoordsDDL luscomposantes produites
1-Dw, thetakappa, gamma
2-Du_x, u_y, r_zeps, kappa, gamma
3-Dsixeps, kappa_y, kappa_z, torsion, gamma_y, gamma_z

C’est le produit B · u, avec le B de l’élément (models::beam::b_into) — celui-là même dont la rigidité intègre Bᵀ D B et dont les forces internes intègrent le transposé. Les déformations sont évaluées à chaque point de Gauss : la courbure varie le long d’une travée non chargée (M' = V), seul le cisaillement est constant (V' = 0).

Le matériau est exigé, et c’est la signature honnête : Φ = 12EI/(G·A_s·L²) décide de la distribution de courbure. Un matériau sans constantes de cisaillement — celui d’une poutre de Bernoulli, qui ne demande ni G ni A_s — signifie Φ = 0, si bien que le même opérateur sert les deux théories sans qu’on ait à lui dire laquelle. Le résultat se donne au comportement pour obtenir les efforts de section.

shell_deformation(field, fespace, formulation) → ElementField

Déformations généralisées d’une coque, depuis les six DDL u_x…u_z, r_x…r_z. formulation vaut "thick" ou "kirchhoff" : les lignes de membrane, de vrillage et de cisaillement sont partagées, mais celles de flexion sont toute la différence entre les deux.

formulationcomposantes produites
thickeps_xx, eps_yy, eps_xy, kappa_xx, kappa_yy, kappa_xy, drill, gamma_xz, gamma_yz
kirchhoffles mêmes, sans les deux déformations de cisaillement

Toutes dans le repère local de la facette. drill est le résidu de vrillage θ_z − ω_z, dont l’effort conjugué est un moment que la rigidité intègre comme les autres.

Le cisaillement transverse est constant par élément, échantillonné au point réduit — le point même où la rigidité l’intègre. Ce n’est pas une simplification : c’est l’intégration réduite qui empêche une coque mince de bloquer, lue de l’autre côté. L’échantillonner ailleurs rapporterait une déformation que l’élément ne porte pas.

Le B du modèle : strain(model, solution, materials) → ElementField

Les producteurs ci-dessus connaissent un espace EF, pas une loi. strain est leur pendant au niveau du modèle : il demande à chaque loi quel producteur la nourrit, l’applique à ses inconnues sur son espace, et rend l’entrée de toutes les lois du modèle — le EPSI de Cast3M, étendu à toutes les physiques. Aussi en méthode : model.strain(solution, materials).

sous-modèleproducteurzone rendue
élasticité, plasticité, endommagement, barredeformationeps_*, ou la mesure de la loi (E_*, H_*)
conduction, Fickgradientgrad_T_*, grad_c_<espèce>_*
rayonnementinterp_to_gaussT aux points
poutres Bernoulli, Timoshenkobeam_deformationeps, kappa*, gamma*, torsion
coqueshell_deformationeps_*, kappa_*, drill, gamma_*

Chaque loi lit ses inconnues de solution par leur nom : une même solution — déplacement, température, multiplicateurs — sert tout le modèle, et une inconnue absente vaut zéro. Les sous-modèles sans loi (contraintes, charges, échanges) ne produisent rien. materials n’est lu que par les poutres, dont la répartition flexion / cisaillement dépend de Φ = 12EI/(G·A_s·L²). Ce que la loi déclare, c’est son producteur (Behavior::strain_operator, méthode requise : une physique nouvelle ne peut pas l’oublier) ; l’opérateur tient la vue de la solution une fois pour toutes les lois.

strain et internal_forces sont transposés. Les éléments finis portent deux couples de variables duales : déplacement / force aux nœuds, déformation / contrainte aux points de Gauss. Le travail se mesure des deux côtés — f·u aux nœuds, Σ_g w_g |J_g| σ:ε aux points — et Bᵀ W est l’adjoint de B pour ces deux produits :

\[ \sigma \cdot W (B u) = (B^\mathsf{T} W \sigma) \cdot u \qquad \forall\, u,\ \sigma , \]

le principe des travaux virtuels écrit au niveau discret :

   nœuds                         points de Gauss
   u      ───── strain (B) ─────▶  ε
                                   │  integrate_behavior (la loi)
   f_int  ◀── internal_forces ───  σ
                (Bᵀ W)

D’où K = Bᵀ W D B, symétrique si et seulement si D l’est, et, pour une loi linéaire, internal_forces(integrate_behavior(strain(u))) = K·u à l’arrondi près — ce que vérifie l’exemple. divergence est le cas scalaire du même Bᵀ, et l’adjoint de gradient. En grandes transformations, la transposition lie la variation δE = sym(Fᵀ∇₀δu) aux efforts : internal_forces lit alors la contrainte nominale P = F S (cf. Grandes transformations), et la tangente gagne la rigidité géométrique. Elle ne tient plus pour un terme non variationnel (advection, pression suiveuse, relation non associée), ni dès que les deux côtés n’utilisent pas les mêmes points : c’est pourquoi shell_deformation lit le cisaillement au point réduit où la rigidité l’intègre.

Un modèle couplé se passe de consolidate. Deux lois sur un même maillage (thermique et mécanique) donnent deux zones sur un même espace. Chaque consommateur — integrate_behavior (entrée et état précédent), internal_forces, tangent, geometric — prend la zone qui porte ce qu’il lit, comme le matériau est déjà résolu par ses composantes.

Pourquoi strain et pas deformation. En anglais, deformation désigne la transformation elle-même (F, et les tenseurs C, b, qui valent I en mouvement rigide) ; strain en est une mesure, qui s’annule en mouvement rigide (ε, E, ln U), et le mot consacré pour les déformations généralisées des poutres et coques. L’opérateur géométrique, qui produit les deux, garde donc deformation ; ce que lit une loi est un strain. Le gradient thermique en est un abus assumé, comme les internal_forces d’un problème thermique.

# B: each law gets its own input from one solution — a strain for the solid,
# a temperature gradient for conduction.
eps = couple.strain(solution, materiaux)
repos = couple.initial_state(materiaux)  # l'état de départ des deux lois
etat = couple.integrate_behavior(eps, materiaux, repos)  # the laws
f_int = couple.internal_forces(etat, solution, materiaux)  # Bᵀ: back to the nodes

Maths élément par élément

Onze fonctions appliquent une fonction scalaire à chaque valeur d’un champ et renvoient un nouveau champ du même type (style numpy). Elles acceptent indifféremment les quatre saveurs de champ — NodeField, SubNodeField, ElementField, SubElementField — et dispatchent par type.

PythonEffet
abs(field)valeur absolue
sqrt(field)racine carrée (nan pour les négatifs)
exp(field)exponentielle eˣ
log(field)logarithme népérien (-inf/nan pour ≤ 0)
log10(field)logarithme base 10
cos(field) / sin(field) / tan(field)trigonométrie (radians)
sinh(field) / cosh(field) / tanh(field)trigonométrie hyperbolique

Les résultats sont non bornés, comme en numpy : aucune protection sur le domaine (log de ≤ 0 donne -inf/nan, sqrt d’un négatif donne nan). Ces fonctions se combinent à l’arithmétique scalaire des champs (f + s, f * s, cf. Champ) pour bâtir des expressions par composante.

# Atténuation exponentielle d'un champ de température.
attenue = pyrucast.field.exp(temperature * -0.1)

# Magnitude of a field (combined with scalar field arithmetic).
amplitude = pyrucast.field.abs(signal)

Réduction

Deux produits scalaires, à ne pas confondre — ils diffèrent par ce qui est réduit et donc par le type du résultat :

PythonCast3MRéduitRésultat
xty(x, y)XTYtout (nœuds/points × composantes)un float
psca(x, y)PSCAles composantes seules, nœud par nœudun champ à une composante "psca"

Les deux exigent la même saveur d’opérandes (NodeField, SubNodeField, ElementField, SubElementField), alignent les composantes par nom (l’ordre peut différer) et associent les valeurs comme l’arithmétique de Champ : la somme ne porte que sur les (support, composante) partagés par les deux champs ; un support ou une composante d’un seul côté n’a pas de vis-à-vis et ne contribue pas. Un nœud que plusieurs zones portent compte une fois dans xty.

  • xty (produit scalaire global, dot / dot_field) → un float ;
  • psca (pscal / pscal_field) → un champ (composante "psca"), une zone par support partagé.

xty(x, y) → float

Produit scalaire global des deux champs entiers :

\[ x \cdot y = \sum_i \sum_c x_{i,c}\, y_{i,c}, \]

la somme parcourant toutes les valeurs, un nœud que plusieurs zones portent compté une fois. Le résultat est un unique float : le produit scalaire qui sert au calcul d’énergie (F·u), aux normes de résidu, etc. L’addition flottante n’étant pas associative, le total dépend du nombre de threads jusqu’au dernier ULP — comme le solveur, ce n’est pas reproductible bit à bit.

# External strain energy: work of the nodal forces in the displacement field
# (same components, same mesh).
energie = pyrucast.measure.xty(forces, deplacements)

psca(x, y) → champ (même saveur que les entrées)

Produit scalaire nœud par nœud (ou point par point) — réduction sur les composantes seules, le support est conservé :

\[ p_i = \sum_c x_{i,c}\, y_{i,c}. \]

Le résultat est un nouveau champ de la même saveur que les entrées, portant une seule composante "psca" : la valeur du produit scalaire à chaque nœud. Chaque sortie est écrite une fois (par nœud) ⇒ indépendant du nombre de threads.

# Squared norm of a vector field, node by node.
norme2 = pyrucast.field.psca(vitesse, vitesse)  # one-component field, "psca"

integral(field, component, fespace=None) → float

Intègre un champ sur son support par la quadrature éléments finis, ∫_Ω f dΩ — le total d’une composante (p.ex. la résultante d’une densité de force distribuée) :

\[ \int_\Omega f \, d\Omega \;=\; \sum_{\text{cell}} \sum_g f(\text{cell}, g)\, |J|_g\, w_g . \]

  • sur un NodeField : les valeurs nodales sont relevées aux points de Gauss par les fonctions de forme, ∫ Σ_i f_i N_i dΩ — fespace est requis ;
  • sur un ElementField : les valeurs (déjà aux points de Gauss) sont intégrées directement — fespace est ignoré.

Comme xty, la somme flottante dépend du nombre de threads jusqu’au dernier ULP. En interne, la réduction parallèle sur les cellules passe par le driver kernel::reduce_cells.

# Resultant of a surface force density f_y on a plate (through N_i).
r_y = pyrucast.measure.integral(densite, "f_y", fespace=fes)
# Measure of the domain: ∫ 1 dΩ.
aire = pyrucast.measure.integral(champ_unite, "u", fespace=fes)

Somme et xtx

Pour une résultante de forces déjà nodales (sortie de internal_forces, réactions…), la résultante est une simple somme par nœud — exposée comme méthode, à côté de min / max :

RéductionRéduitRésultat
field.min(comp) / field.max(comp)une composanteun float (exact)
field.min() / field.max()toutes les composantesun float (exact)
field.sum(comp)une composante (Σ nœuds/points)un float
xtx(field)toutes les valeurs au carré (Σ v², XTX)un float
xtx(field, components=[…])seules ces composantes au carréun float

Sur un champ aux nœuds, sum et xtx comptent une fois un nœud que plusieurs zones portent : il n’y a qu’une valeur (voir Champ). Elles regroupent la somme en parallèle : dépendantes du nombre de threads au dernier ULP (contrairement à min / max, exactes quel que soit l’ordre).

Appelées sans argument, min et max lisent le champ comme la liste plate de ses valeurs — toutes composantes confondues, et, au niveau agrégat, toutes zones confondues. C’est l’esprit de xtx, et la même mise en garde : sur un champ dont les composantes ne portent pas la même unité (sigma_xx à côté de sigma_xy), la réponse est « la plus petite valeur là-dedans », pas une grandeur physique — il faut alors nommer la composante. Pour la liste des extremums composante par composante, une compréhension suffit : {c: f.min(c) for c in f.components()}.

Par défaut xtx somme toutes les composantes. En passant components, on restreint la somme à celles-là (les autres sont ignorées) — utile pour mesurer la norme d’un résidu sur un sous-jeu de degrés de liberté. Une composante absente d’une zone y est simplement ignorée ; l’appel n’échoue que si aucune zone ne porte l’une des composantes demandées.

# Resultant of a nodal force field, component by component.
rx = forces.sum("f_x")
ry = forces.sum("f_y")
# Squared norm of the residual, for a convergence test.
r2 = pyrucast.measure.xtx(residu)
# The same norm, restricted to the translation components alone.
r2_uy = pyrucast.measure.xtx(residu, components=["f_y"])
# Extrema of a named component…
fy_max = forces.max("f_y")
# …or, without an argument, of the whole field, components pooled.
partout = forces.min()

À venir

Le module est conçu pour accueillir d’autres dérivations sur le même patron (champ, espace EF) → champ : projection L² vers les nœuds (project_to_nodes), mesures non linéaires de déformation (Green-Lagrange). Elles arriveront avec les premiers besoins.

Opérateurs d’assemblage

Le module ops::matrix transforme un Model en une Matrice (raideur, masse). Les seconds membres répartis — internal_forces, external_forces — sont eux aussi des assemblages, mais leur résultat est un vecteur nodal : on se range par la sortie, ils vivent donc sous ops::node_field. Les intégrandes par physique vivent sous src/models/ ; cette couche oriente : boucle sur les sous-modèles, mise en place des DOFs, accumulation dans la matrice globale.

stiffness(model, materials) → Matrix

Assemble la matrice de raideur K couvrant tous les DOFs du modèle (primaux ⊕ multiplicateurs). Chaque SubModel contribue un ou plusieurs blocs SubMatrix (une physique volumique → 1 bloc ; une contrainte de Dirichlet → les blocs C + Cᵀ), accumulés dans une seule matrice. Les conditions limites n’ont pas de statut spécial : ce sont des sous-modèles comme les autres.

materials est l’ElementField des propriétés : pour chaque sous-modèle qui en a besoin, l’assembleur sélectionne la zone dont le SubFiniteElementSpace correspond au sien (matériaux par zone). Les sous-modèles sans matériau (Dirichlet…) ignorent ce champ.

materials = pyrucast.element_field.material_field(model, [("k", 1.0)])
K = pyrucast.matrix.stiffness(model, materials)
print(K)  # Matrix: n row(s) × n col(s), …

mass(model, materials) → Matrix

Assemble la matrice de masse consistante M (Cast3M MASS), ou la matrice de capacité thermique C pour un modèle thermique (Cast3M CAPA). La mécanique assemble M = ∫ ρ · N_i N_j dx (matériau rho) ; la conduction assemble C = ∫ ρ c_p · N_i N_j dx (matériau rho, cp). Une physique sans terme de masse (bord de convection, contrainte de Lagrange) ne contribue rien.

materials fournit les coefficients par zone, exactement comme stiffness. La densité rho est une composante facultative des physiques mécaniques (comme alpha), rho et cp des physiques thermiques : la raideur / conductivité n’en a pas besoin, mais la masse / capacité les exige (erreur claire sinon).

materials = pyrucast.element_field.material_field(
    model, [("E", 210.0), ("nu", 0.3), ("rho", 7800.0)]
)
M = pyrucast.matrix.mass(model, materials)

lump(matrix) → Matrix

Concentre (lumping, Cast3M LUMP) une matrice assemblée en une matrice diagonale par somme de lignes : chaque terme diagonal devient la somme de sa ligne, les extra-diagonaux sont supprimés. Appliqué à une matrice de masse / capacité consistante, on obtient la masse diagonale (lumpée), qui conserve la masse totale (Σ_i M_lump[i,i] = Σ_ij M[i,j]) — la forme découplée bon marché des schémas explicites. La matrice d’entrée doit être assemblée et carrée, sa ligne i et sa colonne i portant le même nœud. Le résultat garde les lignes et les colonnes de l’entrée, dans le même ordre, avec un bloc diagonal par support de lignes : il se résout, se multiplie par un champ et se sauvegarde comme l’entrée.

M = pyrucast.matrix.mass(model, materials)
M_lumped = pyrucast.matrix.lump(M)  # diagonale

geometric(model, materials, stress) → Matrix

Assemble la matrice de rigidité géométrique (initial-stress) K_g (Cast3M KSIG) : K_g = ∫ Gᵀ σ̂ G, le terme de raidissement sous précontrainte, pour le flambement et les analyses précontraintes. Le noyau K_g[(i,a),(j,b)] = δ_ab ∫ ∇N_i · σ · ∇N_j est indépendant de la loi.

stress est le champ de contrainte de Cauchy courant (composantes Voigt sigma_*, typiquement la sortie de behavior.integrate), résolu par zone comme materials. materials sert encore à résoudre chaque zone mécanique (E, nu).

Kg = pyrucast.matrix.geometric(model, materials, stress)

tangent(model, materials, deformation, prev, dt=None) → Matrix

Assemble la matrice tangente cohérente (algorithmique) K_t = ∫ Bᵀ D_alg B (Cast3M KTAN), qui donne la convergence quadratique du Newton non-linéaire.

Il prend les mêmes arguments que integrate_behavior, et pour la même raison : D_alg est la dérivée du pas ε(B) ↦ σ(B) à état A figé, donc les deux extrémités du pas sont dans sa définition. prev est l’état de A : au premier pas, l’état de repos initial_state(model, materials).

Aucun champ de modules n’est matérialisé : il n’aurait eu que cet assembleur pour lecteur, et il pesait de 6 à 21 réels par point de Gauss. D_alg est évalué au point, ce qui a aussi sorti sa dérivation de behavior.integrate — pour les huit lois plastiques sans forme fermée, COMP payait treize retours radiaux par point au lieu d’un, à chaque itération, que la tangente serve ou non. model.tangent_source() dit d’avance ce qu’une tangente coûtera. materials résout chaque zone comme stiffness.

Une physique linéaire (conduction, Fick, échanges, barres, poutres, coques) et les contraintes (Dirichlet, MPC, contact…) n’ont pas de loi à dériver : leur tangente est leur raideur, et c’est elle qui est assemblée, sans lire deformation. Une physique non linéaire sans tangente propre (Mazars, Damage TC, SiC/SiC) fait échouer l’appel plutôt que de rendre un bloc nul.

strain = pyrucast.element_field.deformation(u, fes)
repos = pyrucast.element_field.initial_state(
    model, materials
)  # premier pas : l'état de repos
Kt = pyrucast.matrix.tangent(model, materials, strain, repos)

Composition : assemble(&mut Matrix)

Une matrice est assemblée une fois, sur la configuration active à ce moment-là. stiffness, mass, geometric et tangent rendent une matrice assemblée, dont les valeurs ne bougent plus : ni quand une autre configuration devient active, ni par un second assemble(), qui ne fait alors rien. Les valeurs d’une autre configuration s’obtiennent en refaisant la matrice.

assemble() sert aux blocs réunis sans leurs valeurs : une SubMatrix ajoutée par add_sub, l’union |, filter, exclude. Les blocs calculés de stiffness (une recette, des valeurs produites au scatter), Matrix::finalize ne sait pas les assembler seul ; m.assemble() le fait, sur la configuration active. C’est une méthode et non une fonction libre : elle mute un seul conteneur en préservant son invariant, exactement comme sa voisine finalize. Elle construit le motif creux depuis les blocs seuls (sans Model) et y disperse les valeurs :

#[test]
fn ajouter_un_bloc_invalide_l_assemblage() -> Result<()> {
    let coords = Handle::new(Coords::new(1)?);
    let a = Node::create_in(coords.clone(), &[0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0])?;
    let mut m = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
    m.add_cell(&[a.id(), b.id()])?;
    let fes = FiniteElementSpace::lagrange1(&m)?;
    let imposed = mesh::poi1_from_nodes(std::slice::from_ref(&a))?;
    let conduction = model::heat_conduction(&fes, MaterialSymmetry::Isotropic)?;
    let model = conduction.union(&model::dirichlet(
        &imposed,
        &conduction,
        "T",
        Default::default(),
    )?)?;
    let materials = element_field::material_field(&model, &[("k", 1.0)])?;
    let support = mesh::to_poi1(&m)?.get(0)?;
    let bloc_supplementaire = SubMatrix::new(
        support.clone(),
        support,
        vec!["q".into()],
        vec!["T".into()],
        DofOrdering::NodesThenVars,
        Symmetry::Full,
    )
    .unwrap();

    let mut k = matrix::stiffness(&model, &materials)?;
    k.add_sub(Handle::new(bloc_supplementaire))?; // invalide l'état assemblé
    k.assemble()?; // réassemble, nouveau bloc inclus

    assert!(k.n_rows()? > 0);
    Ok(())
}
k = pyrucast.matrix.stiffness(model, materials)
k.add_sub(bloc_supplementaire)
k.assemble()

Contrairement à stiffness, ce chemin ne consulte pas le motif mémoïsé sur le Model (il n’y a pas de Model ici) et reconstruit la sparsité à chaque appel — adapté à la composition ponctuelle ; l’assemblage répété d’un modèle fixe reste sur stiffness.

La somme

+ additionne les valeurs de ses opérandes, chacune telle qu’elle a été assemblée, sur l’union de leurs DOFs : le résultat est assemblé et se résout tel quel. Rien n’est recalculé. Avec le facteur scalaire des blocs (bloc * s, bloc / s — voir Matrice creuse), c’est l’opérateur de la dynamique, M/dt + K :

sys = m / dt + k  # assemblée : se résout telle quelle
u = pyrucast.solver.solve(sys, rhs)

Deux matrices assemblées sur deux configurations s’additionnent donc telles quelles. C’est ce que fait le pas à pas en grands déplacements : la capacité sur la référence, la conduction et les échanges sur la pièce déformée (voir Grandes transformations).

K_repos = pyrucast.matrix.stiffness(model, materials)  # sur la configuration active
with coords.in_config(etiree):
    K_etiree = pyrucast.matrix.stiffness(model, materials)  # sur la barre étirée

# Chaque matrice garde les valeurs de sa configuration ; la somme les additionne.
somme = K_repos + K_etiree

Un opérande dont des blocs calculés n’ont jamais été assemblés (une sortie de filter ou de |) n’a pas de valeurs à additionner : la somme lève une erreur plutôt que de les calculer en silence sur la configuration du moment. get fait de même.

Chargement réparti : model.flux(fespace, target, dual) → Model

L’analogue de FLUX / SOUR de cast3m. Ce n’est pas un opérateur mais une physique : une charge répartie est un terme de la forme variationnelle comme un autre, simplement le premier dont le terme entier siège à droite du signe égal. Sa dérivée par rapport à la solution est nulle, donc elle ne contribue à aucune matrice ; on lui demande sa contribution par external_forces.

Elle transforme une densité φ — lue dans le matériau sous le nom phi_<dual> — répartie sur un bord (ou un volume) en charges nodales cohérentes

\[ f_i = \int_\Gamma \varphi\, N_i\, d\Gamma \approx \sum_{\text{cell}} \sum_g \varphi(\text{cell}, g)\, N_i(\xi_g)\, |J|_g\, w_g, \]

accumulées par nœud dans un NodeField — une zone par sous-espace EF — sur la ligne duale dual (par exemple "q" en thermique, "f_x" en mécanique). Une charge n’a pas de primale : elle écrit dans la ligne duale d’une autre physique et n’introduit aucune inconnue.

C’est aussi pourquoi elle reçoit target, le modèle qu’elle charge. Le nom de la ligne duale ne dit pas à quelle nature elle appartient — l’utilisateur le choisit librement —, mais le modèle chargé, si : on y cherche le sous-modèle qui déclare cette duale, ce qui donne du même coup la nature de la charge et la preuve que la ligne est bien assemblée par quelqu’un. Une duale mal tapée bâtit alors une erreur de construction, là où elle produisait une charge muette.

La densité vaut ce que le champ matériau y met : uniforme si on la passe en scalaire à material_field, variable par point de Gauss si on bâtit l’ElementField soi-même. Un ambiant oublié n’est plus possible non plus — une densité absente est refusée à l’assemblage, par son nom.

La mesure |J| venant du sous-espace EF, un bord s’intègre directement : une arête SEG2 plongée dans un Coords 2-D s’intègre comme une ligne (Jacobien manifold), une surface comme une aire.

Le mode normal : une pression. La ligne "n" désigne une densité portée par la normale de la maille, versée dans toutes les lignes de déplacement de la cible à la fois (f_x, f_y, f_z) — le PRES de Cast3M :

\[ f_{ia} = \int_\Gamma \varphi_n\, n_a\, N_i\, d\Gamma . \]

La normale est celle de la configuration active : (t_y, −t_x) pour une arête de tangente t, qui sort de la matière quand le contour est parcouru dans le sens trigonométrique ; une pression p qui pousse sur la paroi s’écrit alors phi_n = −p. Évaluée sur une configuration posée en X + u, la charge suit la paroi — sans raideur de chargement, comme dans Cast3M. Voir Grandes transformations.

Rien n’oblige une charge à rejoindre le modèle qu’elle charge : ne contribuant à aucune matrice, elle se tient très bien en modèle à elle seule, avec sa propre densité. C’est ce qu’on fait quand deux charges alimentent la même ligne duale avec des densités différentes.

# Uniform flux Q on the left edge (a SEG2 mesh), poured into the dual row
# "q" of the loaded model — it is the one that owns that row and gives its
# kind to the load. The load is a sub-model: its density lives in the
# material, under the name "phi_q", and its term is asked of the model.
conduction = pyrucast.model.heat_conduction(edge_fes)
charge = pyrucast.model.flux(edge_fes, conduction, "q")
densite = pyrucast.element_field.material_field(charge, [("phi_q", Q)])
load = pyrucast.node_field.external_forces(charge, densite)
rhs = load | other_loads

Exemples complets de bout en bout : Conduction thermique (carré chauffé) et Élasticité (traction).

Règle invariante : un Model = une Matrice

stiffness et mass produisent chacune une seule Matrix pour tout le modèle. Le solveur reçoit donc une matrice + un second membre — pas de système point-selle composé à jongler côté utilisateur. Voir Modèle physique et Solveur.

Opérateurs de comportement

Le module ops::element_field::behavior intègre la loi de comportement d’un Model — le COMP de cast3m (« intégrer le comportement ») ; l’opérateur ops::node_field::internal_forces en calcule les forces internes — le BSIG de cast3m (∫ Bᵀ σ).

initial_state(model, materials) → ElementField

L’état de repos du modèle : ce que porte chaque loi avant que rien ne se soit passé. C’est l’état de A du premier pas, à donner comme prev à integrate_behavior et à matrix.tangent.

Il a une zone par sous-modèle portant une loi, sur son sous-espace de comportement, avec toutes les composantes que la loi écrit, si bien que la loi y relit tout ce qu’elle attend. Presque toutes les lois partent de zéro : \( \sigma = 0 \), \( \varepsilon = 0 \), \( \varepsilon_p = 0 \), \( p = 0 \). Une loi dont une variable interne part d’une constante du matériau la lit dans materials : la porosité de Gurson part de f_0. C’est pourquoi le matériau est un argument. Les sous-modèles de contrainte, qui n’ont pas de loi, sont ignorés. La méthode modele.initial_state(materiaux) fait de même.

integrate_behavior(model, deformation, materials, prev, dt=None) → ElementField

Là où stiffness produit la linéarisation du modèle (une matrice), integrate_behavior produit la réponse ponctuelle exacte comme champ aux points de Gauss.

C’est un montage incrémental A → B : la loi intègre le comportement entre l’état convergé du début de pas A et la déformation de fin de pas B.

  1. l’entrée de déformation ε(B) est produite par strain(model, u, materials), qui applique à chaque loi le producteur qu’elle déclare — deformation (ε), gradient (∇T…), beam_deformation (κ, γ), shell_deformation (ε, κ, γ). Ces producteurs géométriques ne dépendent que de l’espace EF et restent disponibles pour construire une entrée à la main ;
  2. prev est l’état de A : la sortie du pas précédent, soit la contrainte σ(A), les variables internes VAR(A) et la déformation ε(A). Au premier pas, c’est l’état de repos initial_state(model, materials), A étant la configuration de référence ;
  3. dt est l’incrément de temps, None pour une loi indépendante du temps ; une loi de fluage ou de viscoplasticité lève une erreur sans lui ;
  4. integrate_behavior prend ces entrées plus le matériau par zone et applique la loi de chaque physique point par point ;
  5. il renvoie l’état matériau de B : le flux / la contrainte dual(e) plus les variables internes mises à jour VAR1 — le champ à réinjecter comme prev au pas suivant.

Les sous-modèles de contrainte (Dirichlet…) sont ignorés : un sous-modèle participe ssi il déclare un espace EF de comportement. Les zones de déformation, de matériau et d’état précédent sont appariées par sous-espace EF.

Pourquoi le montage incrémental ? Fournir l’état de A séparément de la déformation de B (plutôt que fusionnés dans un même champ) rend le fil d’état robuste — un ElementField = une zone par support, sans ambiguïté — et ouvre les lois visqueuses, qui exigent un incrément daté. C’est le montage de MFront (s0 / s1 de MGIS).

La convention de toutes les lois. Chaque loi calcule la contrainte en B comme le matériau de B appliqué à la déformation élastique de B : σ(B) = C(B) : ε_e(B). L’élasticité et l’endommagement le font directement, la plasticité par ε_e = ε − ε_p (prédicteur σ_trial = C:(ε(B) − ε_p(A))). Une contrainte écrite ainsi reste sécante quand le matériau dépend de l’état : un module qui baisse avec la température baisse la contrainte à déformation élastique égale, sans aucune correction. Une forme incrémentale σ(A) + C(B):Δε garderait la contrainte de l’ancien module — Cast3M, dont toutes les lois sont incrémentales, ajoute pour cela la correction (D⁻¹(B) − D⁻¹(A)) : σ(A) ; MFront, comme pyrucast, n’en a pas besoin. Seule limite : une précontrainte donnée seule (sans déformation élastique qui la porte) n’est pas représentée par une loi plastique ; elle s’introduit comme une déformation plastique initiale ε_p = −C⁻¹σ₀.

La mesure que lit la loi. Une loi continue lit la mesure de déformation choisie à sa construction (strain_measure) et écrit sa duale, chacune sous son nom : eps_* / sigma_* ("linear"), E_* / S_* ("green_lagrange", la Piola–Kirchhoff 2), H_* / T_* ("hencky"). Les équations de la loi ne changent pas ; ce qu’elles relient, si. La convention ci-dessus vaut dans chaque mesure, et l’état reste sur la configuration de référence — cf. Grandes transformations.

Pour une loi linéaire, le résultat est cohérent avec stiffness (∫ Bᵀ·flux = K·u) ; une loi non linéaire s’écarte de cette tangente — c’est tout l’intérêt d’intégrer le comportement exactement.

Boucle multi-pas (fil d’état)

state = pyrucast.element_field.initial_state(
    model, materials
)  # VAR0 = prev : l'état de repos au premier pas
for step in range(1, nsteps + 1):
    # the step's load → Newton loop on u
    eps = pyrucast.element_field.deformation(u, fes)  # ε(B)
    out = pyrucast.element_field.integrate_behavior(model, eps, materials, prev=state)
    # F_int (BSIG), résidu, correction de u
    state = out  # commit: prev ← VAR1 for the next step

Exemple : efforts de section d’une poutre

# Solution (w, theta) already obtained from the solver.
eps = pyrucast.element_field.beam_deformation(solution, fes, materials)  # (κ, γ)
repos = pyrucast.element_field.initial_state(
    model, materials
)  # l'état de départ de la loi
forces = pyrucast.element_field.integrate_behavior(model, eps, materials, repos)
# forces porte le moment M = E·I·κ et l'effort tranchant V = G·A_s·γ.

Exemple : résultantes d’une coque

Le même montage, avec la formulation en argument : ce sont ses lignes de flexion qui distinguent thick de kirchhoff.

# Solution (six DOFs per node) already obtained from the solver.
eps = pyrucast.element_field.shell_deformation(solution, fes, "thick")
repos = pyrucast.element_field.initial_state(
    model, materials
)  # l'état de départ de la loi
forces = pyrucast.element_field.integrate_behavior(model, eps, materials, repos)
# forces carries the membrane resultants N, the bending ones M, the
# vrillage M_drill, et — en `thick` — l'effort tranchant Q.

Les pages Barre, Élasticité et Timoshenko détaillent l’intégrande de comportement (COMP) de chaque physique. Pour les lois non linéaires avec variables internes (VAR0 → VAR1), voir Plasticité parfaite (retour radial von Mises) et Endommagement de Mazars.

internal_forces(model, state) → NodeField

Les forces internes f = ∫ Bᵀ σ dΩ (le BSIG de cast3m) sont la transposée de l’opérateur de déformation B : là où strain applique B au déplacement (ε = B·u), internal_forces applique Bᵀ à la contrainte et rassemble le résultat aux nœuds. C’est la généralisation mécanique de divergence (qui est exactement Bᵀ q pour un transport scalaire) : une composante de sortie par DDL dual.

state est le champ d’état matériau renvoyé par integrate_behavior. Chaque sous-modèle porteur d’un comportement applique son propre Bᵀ — c’est pourquoi l’opérateur prend un modèle et pas un simple espace EF : un même SEG2 peut être une barre (Bᵀ axial, DDL déplacement) ou une poutre (Bᵀ à deux quadratures flexion + cisaillement, DDL w, θ), et seul le modèle tranche. Solides continus, barres et poutres sont donc tous couverts.

Pour une loi linéaire, le résultat égale la rigidité appliquée à la solution (K·u) ; pour une loi non linéaire, il donne les forces internes exactes.

Une loi en grandes transformations (strain_measure fini) est servie sur la configuration de référence par la contrainte nominale P = F S, lue entière (P_xx, P_xy, P_yx, P_yy) : f = ∫ P·∇₀N dV₀. On la prépare avec stress_measure — cf. Grandes transformations.

C’est le côté intérieur du bilan Σ f_int = Σ f_ext, dont l’écart est le résidu d’équilibre. Le miroir nodal de stiffness : là où l’assemblage demande à chaque sous-modèle ses blocs de ∂r/∂u, celui-ci lui demande son terme de r. Un sous-modèle qui n’a pas de terme de ce côté n’en déclare aucun et n’apparaît pas dans le résultat.

# Solution already obtained from the solver.
eps = pyrucast.element_field.deformation(solution, fes)  # ε = B·u
repos = pyrucast.element_field.initial_state(
    model, materials
)  # l'état de départ de la loi
sig = pyrucast.element_field.integrate_behavior(
    model, eps, materials, repos
)  # COMP : σ
f_int = pyrucast.node_field.internal_forces(model, sig, solution, materials)
# L'autre côté du bilan. L'élasticité seule n'a aucun terme donné : le champ
# comes back empty, and everything external comes from the hand-built loading.
f_ext_modele = pyrucast.node_field.external_forces(model, materials)
residu = f_ext - f_int  # Σ f_ext − Σ f_int

external_forces(model) → NodeField

L’autre côté du bilan : la donnée de chaque terme, à droite du signe égal. Une physique dont le terme n’est qu’une réponse à u — élasticité, conduction, barre — n’en a aucun, si bien qu’un modèle qui n’en contient que de celles-là rend un champ vide, ce qui est la réponse juste et non un échec.

Séparer les deux côtés est ce qui garde les signes hors des fichiers de physique
l’auteur écrit ses deux moitiés positivement, comme la forme faible se lit, et l’unique soustraction vit chez l’appelant. De quel côté un terme se range est une question de physique — le côté du signe égal où il se trouve — et non de comptabilité.

Sans modèle, ce sont des divergences

Il n’y a pas de variante sans modèle des forces internes, et c’est voulu : privée de son modèle, l’opération ne connaît plus aucune mécanique. Il lui reste la géométrie et des noms, et sous ce jour ∫ Bᵀ σ est exactement la divergence du tenseur des contraintes — une divergence faible par ligne de σ. C’est donc divergence(field, "sigma") qui la rend, avec des composantes div_sigma_x, div_sigma_y… que l’appelant renomme en lignes duales s’il veut en faire des forces.

Ce renommage n’est pas une formalité administrative : c’est l’endroit, et le seul, où ces nombres deviennent de la mécanique.

Barres et poutres ne sont pas couvertes par cette voie — leur B n’est pas le gradient symétrique et leurs DDL ne sont pas un déplacement : pour elles, internal_forces(model, …), qui répartit par physique.

Opérateurs de solveur

Le module ops::solver résout le système linéaire A · x = b issu de l’assemblage. Le back-end est un LU creux parallèle (faer), confiné à ops::solver : il consomme le CSC assemblé par nalgebra-sparse. Chaque crate garde son rôle — nalgebra (primitives), nalgebra-sparse (assemblage/stockage/serde), faer (factorise & résout). Voir Parallélisme.

solve(matrix, rhs, …) → NodeField

Un seul point d’entrée. Résout A x = b où A est la Matrice assemblée et b le NodeField de chargement. L’opération :

  1. obtient la factorisation de A (cache de la matrice, cf. ci-dessous) ;
  2. lit le NodeField de chargement à chacune des lignes de la matrice (les entrées absentes valent 0.0 par défaut) ;
  3. effectue la descente/remontée pour ce second membre ;
  4. emballe la solution dans un NodeField indexé sur les colonnes de la matrice (les primales : déplacements, températures, multiplicateurs…).

La solution a une zone par support colonne des blocs de la matrice, chaque zone vivant sur le handle POI1 du bloc lui-même (aucun support n’est reconstruit — ces supports sont créés une fois à la construction des sous-modèles et réutilisés d’assemblage en assemblage). Elle est donc same_support avec tout champ posé sur ces supports, et deux résolutions successives partagent les mêmes supports : leur arithmétique (a - b, …) s’aligne zone à zone. Sur un modèle contraint, une zone porte les multiplicateurs lambda, que les relations aient été gardées ou éliminées.

Pour poser un autre champ sur ces mêmes supports (p. ex. projeter les forces externes avant de calculer un résidu f_ext − K·u), la matrice expose ses supports en maillages : k.row_mesh() (côté dual, où vivent le second membre et mul_field) et k.col_mesh() (côté primal, où vit la solution) — handles partagés, dédupliqués. restrict(&f_ext, &k.row_mesh()?) s’aligne alors zone à zone avec K·u ; pour un résidu strict (toute composante soustraite, absente lue à 0 — et non passée brute par l’union), restrict_like(&f_ext, &f_int) reprojette aussi sur les composantes.

Une matrice singulière (p. ex. conditions aux limites oubliées) produit un pivot nul ⇒ solution non finie ⇒ erreur explicite.

K = pyrucast.matrix.stiffness(model, materials)
solution = pyrucast.solver.solve(K, rhs)  # factorise puis résout
T = solution.value(some_node, "T")

# Later solves on the SAME matrix: the factorization is reused.
sol2 = pyrucast.solver.solve(K, autre_rhs)  # descente/remontée seulement
sol3 = pyrucast.solver.solve(
    K, autre_rhs, cache=False
)  # refactorizes, without touching the cache

La matrice se suffit

solve ne prend pas de modèle : tout ce qu’il lui faut est dans la matrice.

ce que le solveur doit savoiroù il le lit
quelles lignes sont des relationsles lignes de nom imposed, le nom commun de toutes les contraintes
leurs coefficientsla ligne elle-même
leur multiplicateurla colonne lambda du même nœud
leur sens (=, >=, <=)le bloc C qui les porte (SubMatrix::row_sense), posé par la contrainte à l’assemblage

Le sens voyage avec le bloc : une somme de matrices (K + K_σ + M) garde les blocs de ses opérandes, et l’archive les écrit. Rien ne peut donc contredire la matrice, et une matrice de contact ne peut pas être résolue comme si ses relations étaient des égalités.

Options

optiondéfautrôle
relations"lagrange"comment les relations sont imposées : multiplicateurs, ou élimination ("eliminate")
method"lu"factorisation directe ; "cholesky" exige un système symétrique défini positif
active_set"schur"stratégie de l’ensemble actif, lue seulement si la matrice porte des inégalités
max_iter100borne de la boucle de statut
tol1e-10tolérance de signe sur λ et sur le jeu
cacheTrueréutiliser ce qui est rangé sur la matrice
verbosity"silent"ce que la résolution raconte ("brief", "detailed")

Les voies de la résolution

solve choisit son calcul, sa voie, d’après deux choses : l’option relations, et la présence d’inégalités dans la matrice (un bloc C dont le sens est >= ou <=). Dans la boucle de l’ensemble actif, l’option active_set choisit ensuite comment chaque statut est factorisé. Pour chaque voie, le schéma dit ce qui est calculé, et ce que la matrice garde ensuite dans sa case de cache (section suivante).

solve(matrice, second_membre, relations=…, method=…, active_set=…)
├── relations="lagrange" (défaut)
│   ├── la matrice ne porte aucune inégalité
│   │     calcul : factorisation de la matrice, LU ou Cholesky selon method
│   │     case   : la factorisation
│   └── la matrice porte des inégalités
│         calcul : boucle de l'ensemble actif sur la matrice, toujours en LU
│         ├── active_set="schur" (défaut)
│         │     calcul : une LU de la base (la matrice, toutes les inégalités
│         │              relâchées), faite une fois ; à chaque statut, une
│         │              petite matrice dense, une ligne par relation active
│         │     case   : l'état de l'ensemble actif : les inégalités, la LU de
│         │              la base, les colonnes déjà calculées, le dernier
│         │              statut convergé
│         │     base singulière : calcul de active_set="refactorize"
│         └── active_set="refactorize"
│               calcul : une LU de toute la matrice à chaque statut
│               case   : l'état de l'ensemble actif : les inégalités, le
│                        dernier statut convergé et sa LU
└── relations="eliminate"
    ├── la matrice ne porte aucune relation
    │     calcul : factorisation de la matrice, LU ou Cholesky selon method
    │     case   : la factorisation
    ├── la matrice ne porte que des égalités
    │     calcul : élimination des égalités, puis factorisation du système
    │              réduit K̂ = TᵀKT, LU ou Cholesky selon method
    │     case   : la condensation : T, Tᵀ, K sans ses contraintes, K̂ factorisé
    └── la matrice porte des inégalités
          calcul : élimination des égalités, puis boucle de l'ensemble actif
                   sur le système réduit [[K̂, Bᵀ], [B, 0]], toujours en LU
          ├── active_set="schur" (défaut)
          │     calcul : une LU de la base (le système réduit, toutes les
          │              inégalités relâchées), faite une fois ; à chaque
          │              statut, une petite matrice dense, une ligne par
          │              relation active
          │     case   : la condensation : T, Tᵀ, K sans ses contraintes, le
          │              système réduit, et l'état de l'ensemble actif : les
          │              inégalités, la LU de la base, les colonnes déjà
          │              calculées, le dernier statut convergé
          │     base singulière : calcul de active_set="refactorize"
          └── active_set="refactorize"
                calcul : une LU de tout le système réduit à chaque statut
                case   : la condensation : T, Tᵀ, K sans ses contraintes, le
                         système réduit, et l'état de l'ensemble actif : les
                         inégalités, le dernier statut convergé et sa LU

method ne s’applique qu’aux factorisations directes : la boucle de l’ensemble actif factorise en LU quoi qu’on demande, parce que le système d’un statut n’est jamais symétrique (une relation inactive y devient la ligne λ = 0, sa colonne restant en place).

Les relations sont mises à l’échelle de la structure. Par la voie des multiplicateurs, un système [[K, Cᵀ], [C, 0]] mêle deux ordres de grandeur : la raideur de la pièce (E·h, de l’ordre de 10⁹ pour de l’acier) et les coefficients des relations (1 pour un blocage). Factorisé tel quel, il perd de la précision justement sur les relations : les nœuds bloqués d’une pièce en acier gardaient quelques millionièmes du plus grand déplacement. Avant la factorisation, chaque relation est donc ramenée à l’échelle des inconnues qu’elle lie : sa ligne (imposed) et la colonne de son multiplicateur (lambda) sont multipliées par

\[ s_r = 1{,}5\, \frac{\max_k |K|_k}{\max_k |a_{rk}|}, \]

où \( k \) parcourt les inconnues de la relation, \( a_{rk} \) ses coefficients, et \( |K|_k \) la plus grande valeur, en valeur absolue, de la colonne de l’inconnue \( k \) hors des relations. Une inconnue sans rien à mesurer retombe sur la plus grande valeur de la matrice. Un facteur par relation, et non un seul pour toutes : une relation posée sur une pièce souple, à côté d’une pièce mille fois plus raide, est ramenée à l’échelle de sa pièce, non de l’autre. La solution est ensuite remise à l’échelle : c’est la même, et les relations tiennent à l’arrondi près. La boucle de l’ensemble actif garde ses tolérances dans les unités d’origine (un multiplicateur en newtons, un jeu en mètres). Cela coûte une copie des valeurs de la matrice, faite seulement quand elle porte des relations.

Une relation relâchée tient à l’arrondi près. Dans l’ensemble actif, la ligne d’une relation inactive devient \( d_r\,\lambda_r = 0 \), avec un pivot \( d_r \) de la taille des coefficients de la relation mis à l’échelle. Un pivot de 1 à côté de coefficients de \( 10^{12} \) laissait \( \lambda_r \) à l’arrondi de la résolution, quelques newtons, qui poussaient la pièce par \( C^{\mathsf T} \). Le \( \lambda_r \) rendu vaut ensuite exactement 0.

Factorisation réutilisable (cache transparent)

solve factorise une fois, résout N fois. Ce qui coûte est rangé dans la Matrix, dans une seule case : selon la voie, une factorisation, une condensation ou l’état de l’ensemble actif (schéma ci-dessus). La condensation et l’état de l’ensemble actif contiennent eux-mêmes leurs factorisations. Les résolutions suivantes sur la même matrice reprennent ce contenu et ne font que la descente/remontée, bien moins chère (cas de charge multiples, itérations de Newton, transitoire à matrice constante) ; la boucle de l’ensemble actif repart en plus du dernier statut convergé.

  • Le contenu n’est repris que s’il répond à la demande : la même voie, et pour une factorisation, seule ou dans une condensation, la même method. Sinon il est recalculé et remplace l’ancien : alterner deux voies sur une même matrice recalcule à chaque changement.
  • Il est oublié dès que la matrice change (add_sub), et n’est pas sauvegardé. cache=False ne le lit ni ne le remplit.
  • On ne stocke jamais l’inverse explicite (dense, coûteux, instable) — seulement la factorisation.

L’élimination : relations="eliminate"

Voie alternative pour un modèle contraint : au lieu de border le système par des multiplicateurs de Lagrange, on élimine les contraintes par condensation maître/esclave. Pour chaque relation Σ aₖ·u(nœudₖ, varₖ) = g, un terme esclave s est exprimé par les autres (maîtres) : u_s = (g − Σ_{k≠s} aₖ·u_k)/a_s. Le système se réduit à K̂ û = f̂ avec K̂ = Tᵀ K T, résolu par le même back-end (sur une matrice plus petite et définie, sans degré multiplicateur), puis prolongé u = T·û + u₀.

  • les relations sont lues dans la matrice, comme ci-dessus ; le K physique est son bloc point-selle sans les nœuds multiplicateurs ;
  • récupère en post-traitement le multiplicateur de chaque relation, aₛ·λ = f − K·u à la ligne de son esclave, et le rend à son nœud : la solution est celle de la voie Lagrange, mêmes inconnues sur les mêmes supports ;
  • met en cache la condensation (T, K̂ factorisé) sur la matrice, comme la factorisation ;
  • une matrice sans relation retombe sur la factorisation simple.

Les inégalités ne s’éliminent pas. Qu’une relation unilatérale tienne en égalité dépend de la solution : seules les égalités sont éliminées, et chaque inégalité C_I·u ≥ g_I reste une relation du système réduit, écrite sur les inconnues libres, avec son multiplicateur :

⎡ Tᵀ K T   (C_I T)ᵀ ⎤ ⎡û⎤   ⎡Tᵀ(f − K·u₀)⎤
⎣ C_I T       0     ⎦ ⎣λ⎦ = ⎣ g_I − C_I·u₀ ⎦

Les multiplicateurs des égalités ont disparu (C_E·T = 0) ; ceux des inégalités restent, et la même boucle d’ensemble actif décide lesquelles sont saturées. Le λ de chaque inégalité revient à son nœud multiplicateur, comme par la voie Lagrange ; celui de chaque égalité éliminée est la part propre de son esclave, celle des inégalités (C_Iᵀλ) ôtée.

Périmètre : non chaîné, esclaves disjoints — chaque égalité élimine un esclave distinct, jamais réutilisé comme maître ni esclave ailleurs (couvre la périodicité ; erreur explicite sinon). Une inégalité peut porter sur un esclave : elle passe par T comme toute autre ligne.

K = pyrucast.matrix.stiffness(model, materials)
lagrange = pyrucast.solver.solve(K, rhs)  # système augmenté
condense = pyrucast.solver.solve(K, rhs, relations="eliminate")  # reduced system

Voir l’exemple examples/mpc_condensation.py et la page Contraintes.

Les relations unilatérales : l’ensemble actif

Dès que la matrice porte une relation unilatérale (contrainte construite avec sense=">=" / "<=", ou un contact), solve la résout par la méthode actif/inactif (méthode du statut) : chaque relation est soit active (imposée en égalité, son multiplicateur λ du signe d’une poussée), soit inactive (λ = 0, le jeu reste du côté admissible).

Conditions KKT et boucle de statut

Une relation unilatérale Cᵣ·u ≥ gᵣ (ou ≤) n’obéit pas à une équation mais aux conditions de complémentarité de Karush–Kuhn–Tucker : soit elle est active (Cᵣ·u = gᵣ, le multiplicateur λᵣ non nul), soit inactive (le jeu Cᵣ·u − gᵣ est du côté admissible et λᵣ = 0). Les deux ne peuvent être violées à la fois. On ne sait pas a priori quelles relations sont actives ; la méthode du statut itère dessus :

  1. partir d’un statut d’essai (toutes actives, ou le statut convergé précédent quand le cache est chaud — un warm start) ;
  2. résoudre le système point-selle avec, pour chaque relation inactive, sa ligne de contrainte remplacée par λᵣ = 0 (la matrice garde sa taille) ;
  3. vérifier les signes : une relation active dont le λ tire (signe inadmissible pour son sens) est relâchée ; une relation inactive dont le jeu pénètre est activée ;
  4. aucun changement de statut ⇒ convergence (la boucle finie classique du statut) ; sinon on recommence.

La convention de signe vient du système point-selle assemblé K·u + Cᵀ·λ = f, C·u = g : contre le multiplicateur KKT μ ≥ 0 d’une contrainte ≥ on a λ = −μ. Donc, dans le champ solution : ≥ active a λ ≤ 0 (relâchée si λ > tol) ; ≤ active a λ ≥ 0 (relâchée si λ < −tol).

Les relations d’égalité sont imposées inconditionnellement, comme sans inégalité.

Deux stratégies de factorisation (active_set)

Les deux stratégies parcourent exactement la même trajectoire de statuts (mêmes tests KKT, même résultat convergé) — elles ne diffèrent que par la façon de factoriser le système d’un statut donné.

"refactorize" — refactorisation par itération. La méthode d’origine : à chaque changement de statut, on refactorise le point-selle creux complet (une LU faer par itération). Robuste (aucune hypothèse sur la structure), mais paie une factorisation creuse à chaque pas.

"schur" (défaut) — complément de Schur / opérateur de Delassus. On factorise une seule fois le socle sans inégalités A (physique K + contraintes d’égalité, toutes les relations unilatérales relâchées), on le met en cache sur la matrice, et on obtient chaque statut par une mise à jour dense.

Le point clé : passer une relation r de l’état relâché à l’état actif ne change qu’une seule ligne de A. Dans le socle, la ligne relâchée porte l’identité λᵣ = 0 (un 1 à la colonne du multiplicateur, notée λcolᵣ) ; l’activer y restaure la vraie ligne de contrainte Cᵣ. Restaurer les k relations actives est donc une mise à jour de rang k :

M = A + Σ_{r actif} e_row(r) · (Cᵣ − e_λcol(r))ᵀ
  = A + U Vᵀ,   U = [e_row(r)],   Vᵣ = Cᵣ − e_λcol(r)

La formule de Sherman–Morrison–Woodbury donne alors la solution du statut sans refactoriser A :

x = A⁻¹·b − X · (I + Vᵀ X)⁻¹ · (Vᵀ A⁻¹ b),   X = [A⁻¹·e_row(r)]
  • les colonnes xᵣ = A⁻¹·e_row(r) (une descente/remontée creuse par relation) sont mises en cache paresseusement : calculées la première fois qu’une relation devient active, réutilisées ensuite ;
  • le petit système k × k G = I + Vᵀ X est l’opérateur de Delassus restreint aux relations actives — dense, factorisé par une LU dense (nalgebra) à chaque itération (coût k³/3, négligeable jusqu’à quelques milliers de contacts) ; ses entrées se lisent des colonnes cachées : Gᵢⱼ = δᵢⱼ + Cᵢ·xⱼ − xⱼ[λcolᵢ] ;
  • une itération de statut ne coûte donc plus aucune factorisation creuse — seulement des descentes/remontées sur A (cachée) et une LU dense k × k. Un re-solve à chargement identique ou proche est quasi gratuit.

Repli automatique sur socle singulier. Le socle A doit être inversible, c.-à-d. la structure doit tenir sans aucun contact (bloquée par ailleurs). Un corps simplement posé sur un appui n’a pas ce luxe : A est singulière (mode rigide) alors que la méthode du statut converge très bien. Comme la LU creuse peut factoriser une matrice singulière en valeurs finies fausses sans erreur, la non-singularité du socle est confirmée par un aller-retour A⁻¹·(A·1) ≈ 1 ; s’il échoue, la voie "schur" retombe automatiquement sur "refactorize" (marqué une fois pour toutes sur la matrice). Aucune régression possible : le résultat est le même, seul le coût change.

K = pyrucast.matrix.stiffness(model, materials)  # model with sense=">="
solution = pyrucast.solver.solve(K, rhs)  # the matrix carries the ">="
reaction = solution.value(mult_node, "lambda")  # 0 if the stop is released

# Forcing the old method (refactorization at every step):
sol2 = pyrucast.solver.solve(K, rhs, active_set="refactorize")

Voir la section « Relations unilatérales » de la page Contraintes pour les conditions de complémentarité et la convention de signe.

Calculs plus gros que la RAM

Sur un gros modèle, c’est la factorisation qui sature la mémoire, pas l’assemblage. Mesuré sur un cube de conduction thermique HEX8, face inférieure imposée :

DDLassemblagesolve (Lagrange + LU)relations="eliminate" + method="cholesky"
31 k28 Mo1,01 Go, 10,8 s0,28 Go, 1,8 s
135 k118 Mo10,6 Go, 330 s2,22 Go, 42 s
363 k348 Mo—7,20 Go, 217 s

Premier levier, donc : quand le système éliminé est symétrique défini positif (thermique, élasticité), solve(…, relations="eliminate", method="cholesky") divise le pic par cinq et le temps par huit.

Un cube est le pire cas du remplissage. Une pièce mince ou élancée s’en tient bien en dessous, à nombre de DDL égal.

Déborder sur disque sans être root

Une roue compilée avec la feature spill (Linux ; c’est le cas de la roue Python) sait placer ses grosses allocations dans des fichiers mappés en mémoire plutôt qu’en mémoire anonyme. Le noyau peut écrire ces pages sur disque et les évincer sous pression, puis les recharger au besoin. C’est un swap, sans swap configuré et sans droit root. Les facteurs de faer en profitent sans le savoir.

VariableRôle
PYRUCAST_SPILL_DIRRépertoire des fichiers de débordement. Absente, le débordement est inactif.
PYRUCAST_SPILL_MINTaille, en octets, à partir de laquelle une allocation déborde. Défaut : 64 Mio.
PYRUCAST_SPILL_LOGPrésente, chaque bloc mappé et démappé s’écrit sur la sortie d’erreur.

Les variables sont lues une seule fois, à la première allocation du processus. Il faut donc les poser avant de le lancer, par exemple PYRUCAST_SPILL_DIR=/scratch/moi python calcul.py, et non depuis le script. Un répertoire illisible arrête le processus avec un message. Les fichiers sont anonymes (O_TMPFILE) : rien à nettoyer, même après un plantage.

Le répertoire doit être sur un disque local. /tmp est souvent un tmpfs, c’est-à-dire de la RAM, et y déborder ne sert à rien. Un montage réseau (NFS) rendrait chaque éviction très lente. Il faut aussi la place des facteurs : l’espace est réservé à l’allocation, donc un disque plein fait échouer l’allocation au lieu de planter plus loin.

Même cube à 363k DDL, Cholesky, RAM suffisante, débordement sur ext4, les cinq configurations dans une même série :

Configurationblocs débordéspic anonymepic totaltemps
sans la feature spill—6,84 Go6,84 Go163 s
feature, sans PYRUCAST_SPILL_DIR—6,84 Go6,85 Go159 s
seuil 8 Gio06,87 Go6,88 Go160 s
seuil 1 Gio21,13 Go6,85 Go441 s
seuil 64 Mio250,26 Go6,84 Go475 s

La solution est identique au bit près dans les cinq cas.

Le test ne coûte rien de mesurable : sans répertoire de débordement, ou avec un seuil au-dessus du plus gros bloc, on retrouve le temps du binaire compilé sans la feature — l’écart entre les trois premières lignes est du bruit.

Le pic total ne bouge pas, et c’est voulu : tant que la RAM est libre, le noyau garde en cache les pages des fichiers. Ce qui change, c’est qu’elles sont évinçables. La colonne qui décide si un calcul passe ou se fait tuer est le pic anonyme, qui tombe ici de 6,84 Go à 1,13 Go.

Le prix est payé même sans pression mémoire : une page écrite d’un fichier mappé part sur disque au bout d’une trentaine de secondes, pression ou non, et ce délai n’est réglable que par root. D’où un débordement à la demande, par exécution. Ici, ×2,7 à ×2,9 sur un RAID à ~50 Mo/s utiles ; un NVMe en demanderait beaucoup moins.

Choisir le seuil

Le seuil ne connaît pas les types : tout tampon assez gros déborde, quel qu’il soit. Le monter haut ne laisse partir que les tableaux qui comptent vraiment, et garde en RAM les données petites et souvent relues. Sur le cube ci-dessus, avec un seuil de 1 Gio, deux blocs seulement sont partis sur disque — 5,22 Go, qui sont les valeurs du facteur de Cholesky, et 1,08 Go de tampon de factorisation ; la CSR (146 Mo) est restée en mémoire. Le seuil bas, lui, fait déborder vingt-cinq blocs pour 0,9 Go d’anonyme gagnés de plus.

Un seuil de l’ordre du gigaoctet est donc le réglage d’un gros calcul ; un seuil bas ne se justifie que si la RAM manque à ce point. L’écart de temps entre les deux, environ 8 %, est du même ordre que la variabilité d’une exécution à l’autre.

spill_stats() en Python, spill::stats() en Rust, rendent le seuil, le nombre de blocs débordés, le plus gros, ce qui est mappé à l’instant et le maximum d’un coup — en octets, le seuil valant None quand rien ne déborde. PYRUCAST_SPILL_LOG donne la même chose bloc par bloc, au fil de l’eau, dans l’unité qui se lit le mieux.

stats = pyrucast.spill_stats()
if stats["threshold"] is None:
    pass  # PYRUCAST_SPILL_DIR absente : rien ne déborde, tout est en RAM
else:
    print(f"{stats['count']} bloc(s), le plus gros {stats['largest'] / 2**30:.1f} Gio")
    print(f"au plus {stats['peak'] / 2**30:.1f} Gio mappés d'un coup")

Le journal, lui, ressemble à ceci :

pyrucast spill: +5.2 GB, 5.2 GB mapped
pyrucast spill: +1.0 GB, 6.2 GB mapped
pyrucast spill: -1.0 GB, 5.2 GB mapped

Déterminisme

Contrairement au reste des opérateurs (bit-à-bit identiques quel que soit le nombre de threads), le solveur n’est pas bit-à-bit identique à l’ancien LU dense : pivotage et ordering diffèrent. Les résultats restent dans les tolérances numériques usuelles.

Exemples complets

La résolution de bout en bout (assemblage + contraintes + lecture de la solution et des multiplicateurs) est déroulée sur des cas à solution analytique :

Sauvegarde et relecture

Écrire des objets dans un fichier, et les relire en gardant ce qu’ils partageaient. Un dictionnaire à l’aller, un dictionnaire au retour.

pyrucast.save(
    "etude.pyr",
    {
        "maillage fin": mesh,
        "T (°C)": temperature,
        "materiaux": mat,
        "time step": 0.05,
        "instants": [0.0, 0.1, 0.2],
    },
)

objets = pyrucast.load("etude.pyr")
mesh2 = objets["maillage fin"]
t2 = objets["T (°C)"]

Les clefs sont libres : un espace, un accent, une unité — tout ce qu’une chaîne Python peut porter. C’est la raison du dictionnaire explicite plutôt que des mots-clefs.

La garantie : le partage survit

C’est la propriété pour laquelle ce format existe. Deux champs bâtis sur un même support sont relus sur un support, pas sur deux copies aux mêmes nœuds :

t = pyrucast.NodeField(support, ["T"])
f = pyrucast.NodeField(support, ["f"])
pyrucast.save("etude.pyr", {"T": t, "f": f})

o = pyrucast.load("etude.pyr")
assert len(o["T"] | o["f"]) == 1  # a single zone: the support is one object

L’union fusionne les zones qui partagent un support et laisse côte à côte celles qui n’en partagent pas — c’est l’observable la plus directe du partage. Sans cette garantie, un champ et son maillage relus ne combineraient plus : ils porteraient les mêmes nœuds sans être le même support, et toute l’arithmétique de champs tomberait à côté.

Le mécanisme tient en une phrase : les objets sont écrits sous des identifiants locaux au fichier, et un objet référencé deux fois n’est écrit qu’une fois. Une adresse mémoire n’aurait aucun sens dans un autre processus ; un numéro de fichier, si.

On donne les racines, pas la liste des dépendances

save prend ce qui vous intéresse. Ce dont ces objets ont besoin suit tout seul :

pyrucast.save("m.pyr", {"maillage": mesh})  # also writes the Coords and the submeshes

Il n’y a rien à énumérer, et rien à oublier.

Relire ajoute, ne remplace pas

load fabrique des objets neufs et vous rend le dictionnaire. Ce qui vivait déjà dans votre session n’est pas touché : on peut relire deux fois le même fichier et obtenir deux graphes indépendants, ou relire un maillage à côté de celui qu’on manipule.

Ce que le fichier ne porte pas

La règle est simple : ce qui se recalcule ne s’écrit pas.

Sortent donc du fichier tous les caches et toutes les mémoïsations — la matrice assemblée, la factorisation du solveur, le coloriage des mailles, les tables d’index paresseuses, la copie qu’un champ garde de la connectivité de son support. Tout cela se rebâtit à la première demande, exactement comme sur un graphe construit à la main :

o = pyrucast.load("etude.pyr")
k = pyrucast.matrix.stiffness(o["modele"], o["materiaux"])  # réassemble
u = pyrucast.solver.solve(k, o["chargement"])  # refactorise

Ce n’est pas une économie de place accessoire : la copie qu’un champ nodal garde de sa connectivité pèse autant que le maillage lui-même, et un fichier qui la porterait la porterait une fois par champ.

Les compteurs de références

Ils ne sont pas écrits non plus, et c’est délibéré : un fichier contient certains des objets qui référencent un nœud, pas tous. Un compteur sauvé décrirait un monde qui n’existe plus.

À la relecture, tout est recompté depuis zéro. Les objets se comptent seuls — chaque référence rendue par load compte pour une. Les nœuds sont réincrémentés par les sous-maillages relus.

Conséquence à connaître : un nœud relu n’est protégé que par les objets présents dans le fichier. Un Node que vous teniez dans une variable Python n’est pas archivé — c’est un atome de votre script, pas un objet du graphe. Sauver une Coords seule et la relire donne donc des nœuds à compteur nul, qu’un gc() collectera :

c2 = pyrucast.load("coords_seules.pyr")["c"]
c2.gc()  # collects everything: nothing in the file held those nodes

C’est exactement l’état où l’on serait après avoir reconstruit les mêmes objets à la main sans en garder de Node. Pour qu’un nœud survive, sauvez ce qui l’utilise.

Les valeurs simples

À côté des objets, le fichier accepte un bool, un int, un float, une str, et les listes homogènes de ces quatre types. De quoi ranger le pas de temps, le nom du cas de charge ou la liste des instants avec les champs auxquels ils se rapportent.

Trois refus, nommés plutôt que silencieux : une liste imbriquée ou un dictionnaire (hors périmètre), une liste hétérogène, et un entier hors des 64 bits du format — les entiers Python sont non bornés, le fichier ne l’est pas.

Le fichier

b"PYRUCAST"                       signature, 8 octets
version de format (u32)           toute autre valeur est refusée, jamais convertie
version du crate                  informative : elle sert au diagnostic, jamais au test
enregistrements                   (identifiant, type, octets), en ordre de dépendance
racines                           les clefs que vous avez données

Sauver deux fois les mêmes objets produit le même fichier, octet pour octet : les clefs sont triées, donc les identifiants sont distribués dans un ordre déterministe. Un fichier d’archive se compare, se met sous gestion de version, se hache.

Le format binaire est identique sous Linux et Windows : entiers petit-boutistes normalisés, usize sur 64 bits, f64 IEEE-754, aucun chemin ni séparateur dépendant du système dans les données.

Avant la version 1.0.0, le format peut changer sans préavis. Une version inconnue est refusée avec un message qui nomme les deux numéros — jamais décodée à moitié.

Ce n’est pas un format d’échange

Un fichier .pyr est le format de session de pyrucast : il sert à reprendre un calcul, pas à le publier. Pour donner des résultats à un autre outil, export_vtk existe pour ça, et ParaView le lit nativement.

La distinction n’est pas de la pudeur. Un format d’échange comme HDF5 apporte l’interopérabilité, la lecture partielle et la compression — mais aucune notion d’identité d’objet ni de référence partagée. La garantie du haut de cette page devrait y être reconstruite à l’identique, par-dessus. À l’inverse, un format de session n’a pas à être lisible par des tiers, et gagne à pouvoir casser tant que la bibliothèque n’est pas figée. Confondre les deux les abîme tous les deux.

Côté Rust

#[test]
fn sauver_et_relire_un_graphe_d_objets() -> Result<()> {
    // `tempfile` is not a dependency of the project: a unique name in the system's
    // temporary directory is enough.
    let chemin = std::env::temp_dir().join(format!("pyrucast_doc_{}.pyr", std::process::id()));
    let chemin = chemin.to_str().unwrap().to_string();
    let chemin = chemin.as_str();

    let coords = Handle::new(Coords::new(2)?);
    let a = Node::create_in(coords.clone(), &[0.0, 0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0, 0.0])?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords, ElementType::SEG2));
    mesh.add_cell(&[a.id(), b.id()])?;
    let temperature = NodeField::new(&mesh::to_poi1(&mesh)?, vec!["T".into()])?;

    archive::save(
        chemin,
        &[
            ("maillage fin", &mesh as &dyn archive::ArchiveRoot),
            ("T (°C)", &temperature),
            ("pas de temps", &0.05_f64),
        ],
    )?;

    let mut objets = archive::load(chemin)?;
    let mesh2 = objets.mesh("maillage fin")?; // erreur nommant clef, type attendu, type trouvé
    let dt = objets.float("pas de temps")?;

    assert_eq!(mesh2.cell_count(), 1);
    assert_eq!(dt, 0.05);
    std::fs::remove_file(chemin)?;
    Ok(())
}

À l’écriture les types sont connus du compilateur, une tranche de paires suffit. À la relecture ils ne le sont pas : load rend une table nommée, dont on tire chaque objet avec son type attendu.

Le détail du mécanisme — la découverte des dépendances par la sérialisation elle-même, la détection de cycle, le crochet d’après-relecture — est dans Modèle mémoire et dans la documentation du module archive.

Visualisation

La visualisation des maillages est optionnelle : elle est gardée par des features Cargo et n’est donc compilée que sur demande.

Feature CargoApportDépendances ajoutées
(aucune)rien — bibliothèque de calcul pure—
vizexport PNG + SVG (rendu CPU)plotters
viz-interactive+ fenêtre interactive (souris)plotters, winit, softbuffer

viz-interactive implique viz. Pour un environnement sans serveur d’affichage (CI headless, conteneur), on s’arrête à viz : tout l’export image fonctionne.

Installé depuis PyPI ? Les wheels publiées compilent viz et viz-interactive ; la sdist, sur laquelle pip retombe quand aucune wheel ne correspond à la plateforme, ne compile que le cœur de calcul — mesh.plot() n’y existe pas. pyrucast.__features__ dit ce que porte l’installation en cours, et « Ce que porte chaque distribution » détaille les deux cas.

# Bibliothèque de calcul pure (par défaut).
cargo build

# Avec export PNG/SVG.
cargo build --features viz

# Avec fenêtre interactive.
cargo build --features viz-interactive

# Côté Python (pour les tests pytest) : les features passées en ligne de
# commande *remplacent* celles du pyproject, il faut donc redonner
# `extension-module` en plus de `viz`.
maturin develop --features extension-module,viz

Modèle de point de vue

La caméra est décrite par une structure View, située sur une sphère orientée autour d’un point cible :

  • yaw : azimut en degrés (rotation autour de l’axe Z monde) ;
  • pitch : élévation en degrés (au-dessus du plan XY monde) ;
  • scale : 1.0 = la bounding-box remplit l’image ; >1 zoom, <1 dézoom ;
  • target : point regardé. None ⇒ le centre de la bounding-box de l’objet visualisé ;
  • revolve : sur une géométrie axisymétrique uniquement, balaie la section méridienne pour tracer le corps de révolution (voir plus bas). None (défaut) ⇒ la section plane.

Préréglages disponibles :

#[test]
fn les_vues_predefinies() {
    let _ = View::front(); // yaw=0, pitch=0      : caméra en +X
    let _ = View::side(); // yaw=90, pitch=0     : caméra en +Y
    let _ = View::top(); // yaw=0, pitch=90     : vue du dessus
    let _ = View::iso(); // yaw=45, pitch≈35.26 : isométrique
    let _ = View::default(); // = iso()
}

Convention : yaw = pitch = 0 place la caméra en +X, regard vers l’origine, axe Z vers le haut. Le repère écran qui en résulte est (Y, Z).

Une seule fonction plot

Toute la sortie passe par la même méthode plot(view, save) exposée sur SubMesh et Mesh :

saveEffetCompilation nécessaire
Noneouvre une fenêtre interactive (souris : rotation au glisser, molette : zoom)viz-interactive
Some(path) avec extension .pngécrit un PNGviz
Some(path) avec extension .svgécrit un SVG vectorielviz
Some(path) avec extension .svgzécrit le même SVG, gzippéviz

Tout autre extension est rejetée avec une erreur explicite. Le format vectoriel est ce qui rend ce socle particulièrement utile pour les figures de rapport : on conserve un trait propre quel que soit le zoom.

.svgz : quand une étude sort des figures par centaines

.svgz n’est pas un autre rendu, c’est le .svg compressé : le dézipper rend le fichier octet pour octet. Il pèse environ le dixième, parce qu’un maillage produit un balisage très répétitif, et se lit nativement dans un navigateur ou dans Inkscape.

Il est fait pour s’accumuler sur un disque, pas pour être publié. Sur le web le gain est nul : les serveurs compressent déjà le .svg à la volée, si bien qu’un .svgz ne change pas un octet transféré — et il rendrait binaires des fichiers que git suit très bien en texte. C’est pourquoi les figures de ce livre restent en .svg.

mesh.plot(save="piece.svg")  # to version, to publish
mesh.plot(save="piece.svgz")  # to stack by the hundred

Le .svg lui-même est déjà allégé à l’écriture : le générateur de SVG répète le style complet sur chaque balise, et pyrucast retire ce que le format sait hériter — un dessin identique au pixel près, pour environ la moitié des octets.

Dans la fenêtre interactive uniquement, le point de vue courant view=(yaw, pitch, scale) s’affiche en permanence en haut à droite — le même ordre que le tuple accepté par view=, pour recopier tel quel l’angle atteint à la souris/molette dans un appel plot() ultérieur.

Exemple Rust :

#[test]
fn exporter_un_sous_maillage_en_svg() -> Result<()> {
    let (dossier, coords, _) = scene()?;
    let a = Node::create_in(coords.clone(), &[0.0, 0.0, 0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0, 0.0, 0.0])?;
    let c = Node::create_in(coords.clone(), &[0.0, 1.0, 0.0])?;
    let mut sm = SubMesh::new(coords, ElementType::TRI3);
    sm.add_cell(&[a.id(), b.id(), c.id()])?;

    // Export vectoriel.
    sm.plot(View::iso(), Some(&dossier.join("triangle.svg")))?;
    // Fenêtre interactive (feature `viz-interactive`).
    // sm.plot(View::default(), None).unwrap();
    Ok(())
}

Côté Python, l’API miroir prend des tuples :

import pyrucast

coords = pyrucast.Coords(3)
a = coords.add_node([0.0, 0.0, 0.0])
b = coords.add_node([1.0, 0.0, 0.0])
c = coords.add_node([0.0, 1.0, 0.0])

mesh = pyrucast.Mesh(coords, "TRI3")
mesh.unit().add_cell([a, b, c])

# (yaw, pitch, scale) ; save=None ouvre la fenêtre interactive.
mesh.plot(view=(45.0, 35.264, 1.0), save="triangle.svg")

Nom de figure / de fenêtre (title)

Toutes les méthodes plot(...) — Mesh, SubMesh, NodeField, ElementField — acceptent un argument nommé optionnel title, qui sert de nom de figure :

  • en export fichier (PNG/SVG), il est gravé centré en bas de l’image, dans une bande réservée sous le tracé ;
  • en fenêtre interactive (save=None), il devient le titre de la fenêtre (barre de titre de l’OS).

title=None (défaut) : aucune légende en bas et titre de fenêtre par défaut (pyrucast). Une chaîne vide vaut None.

mesh.plot(
    save="piece.svg", title="cantilever beam"
)  # caption centred at the SVG's bottom
mesh.plot(save="t.svg", field=t_field, title="temperature")  # combines with field
# mesh.plot(title="ma pièce")  # nomme la fenêtre interactive (bloquant)

Pour les courbes d’Evolution / SubEvolution, le title est la légende en haut du graphe (voir plus bas) ; il n’est pas repris en bas. Pour une évolution de champs, il nomme la figure comme pour un champ seul : en bas de l’image exportée, ou en titre de la fenêtre.

Couleur de face par SubMesh

Chaque SubMesh porte une propriété face_color (type RgbColor, format (r, g, b) sur 8 bits) utilisée par la couche viz pour remplir les facettes. Cette donnée n’a aucun effet sur les calculs ; elle est simplement persistée avec le maillage et consommée par plot. Couleur par défaut : un bleu clair (180, 200, 230).

Côté Rust :

#[test]
fn chaque_zone_porte_sa_couleur() -> Result<()> {
    let (_, coords, maillage) = scene()?;
    let mut sm = SubMesh::new(coords, ElementType::TRI3);
    sm.set_face_color(RgbColor::new(220, 60, 60));
    assert_eq!(sm.face_color(), RgbColor::new(220, 60, 60));

    // The same colour for **every** zone of a mesh, without a loop: the method
    // returns the mesh, so it chains.
    let bleu = RgbColor::new(60, 60, 220);
    assert_eq!(maillage.set_face_color(bleu).cell_count(), 1);
    assert!(maillage.iter().all(|z| z.read().face_color() == bleu));
    Ok(())
}

Côté Python :

sm = pyrucast.Mesh(coords, "TRI3")[0]  # view of the single submesh
sm.face_color = (220, 60, 60)
assert sm.face_color == (220, 60, 60)

# The same colour for **every** zone of a mesh, without a loop: the method
# returns the mesh, so it chains.
piece = pyrucast.Mesh(coords, "TRI3").set_face_color((60, 60, 220))
assert all(zone.face_color == (60, 60, 220) for zone in piece)

Quand on appelle Mesh.plot, chaque sous-maillage est rendu avec sa propre face_color, ce qui permet de distinguer visuellement des composants regroupés dans un même maillage (par exemple : peau / cœur / interfaces).

Pour peindre toutes les zones d’un coup, Mesh.set_face_color((r, g, b)) pose la même couleur sur chacune et rend le maillage — les mêmes zones, pas des copies —, ce qui permet de l’enchaîner : pyrucast.mesh.circle(...).set_face_color((220, 60, 60)).plot(). La couleur étant une donnée de tracé, un maillage scellé l’accepte : le sceau fige la connectivité, pas la façon de la dessiner.

Coloration par un champ — NodeField ou ElementField

plot accepte un argument optionnel field — un NodeField ou un ElementField, interchangeables — qui remplace la couleur uniforme par une couleur tirée d’une colormap appliquée aux valeurs du champ.

Le rendu raisonne par élément : pour dessiner un élément, il lui faut des valeurs à ses nœuds, propres à cet élément.

  • NodeField : les valeurs nodales sont lues directement (champ continu par construction ; un nœud absent du support prend la moyenne des nœuds présents).
  • ElementField : les valeurs vivent aux points de Gauss. Les valeurs nodales du tracé viennent d’un moindre carré local à l’élément (fit de l’interpolant Lagrange aux valeurs de Gauss de cet élément). Aucune moyenne entre éléments voisins : les discontinuités inter-éléments — flux, contraintes — restent visibles, c’est une information physique. Avec un seul point de Gauss, le fit dégénère en couleur constante par élément.

Affichage commun aux deux types :

  • Composante affichée : la première composante du champ par défaut ; on en choisit une autre via component="<nom>".
  • Échelle : linéaire entre le minimum et le maximum observés sur le maillage rendu, sauf si on fixe les bornes (voir Bornes).
  • Colorbar : une barre verticale graduée est dessinée sur le bord droit de l’image (bas = borne basse, haut = borne haute), avec le même dégradé que les cellules.
  • Bandeau : en haut de l’image, le nom de la composante affichée et l’intervalle [min, max].

Rendu interpolé (smooth)

Par défaut (smooth=4), la couleur suit les fonctions de forme à l’intérieur de chaque élément : chaque maille est sous-découpée en sous-triangles (TRI3 → n², QUA4 → 2n²) dont la géométrie et la valeur sont évaluées par N_i(ξ) — y compris le gauchissement bilinéaire des QUA4/HEX8. Le filaire noir n’est tracé que sur les arêtes d’origine des éléments. smooth=0 revient à une couleur plate par cellule (moyenne des valeurs nodales) ; monter smooth lisse davantage au prix de n² polygones par élément.

Le sous-découpage est purement graphique et interne à chaque élément : les sous-sommets d’une arête partagée sont évalués séparément de chaque côté, donc les discontinuités d’un ElementField traversent le rendu interpolé sans être gommées.

Tracé d’un champ seul

  • element_field.plot(...) fonctionne sans maillage : chaque zone retrouve son sous-maillage via son sous-espace EF (partagé, pas copié).
  • node_field.plot(...) trace un nuage de points colorés : son support POI1 ne porte pas de connectivité, aucune surface ne peut être inférée — pour des surfaces, passer par mesh.plot(field=...) avec le maillage d’origine.

Échelles de couleur (cmap)

Cinq colormaps sont disponibles, sélectionnées par leur nom (insensible à la casse). Un nom inconnu lève une erreur listant les noms acceptés.

cmapDégradéUsage
"viridis" (défaut)violet → bleu → vert → jauneusage général ; perceptuellement uniforme, lisible en niveaux de gris et pour les daltoniens
"coolwarm"bleu → blanc → rougedonnées signées centrées sur 0 (le blanc marque le milieu de l’échelle)
"hot"noir → rouge → jaune → blancrendu thermique
"gray"noir → blancimpression N&B, superposition
"jet"bleu → vert → rougeancien défaut, conservé

Bornes de l’échelle (vmin / vmax)

Par défaut l’échelle couvre le min/max des valeurs par cellule. On peut fixer l’une ou l’autre borne (ou les deux) — celle laissée à None continue de suivre les données. Utile pour comparer plusieurs figures sur une échelle commune, ou pour centrer une colormap divergente (vmin = -vmax avec "coolwarm").

Côté Rust, l’argument scale (ColorScale) regroupe colormap et bornes :

#[test]
fn tracer_un_champ_avec_son_echelle() -> Result<()> {
    let (dossier, _, mesh) = scene()?;
    let poi1_h = mesh::to_poi1(&mesh)?.get(0)?;

    // A displacement field with 2 components "UX" / "UY" on a POI1. `FieldArg`
    // takes the **aggregate**: a lone zone is lifted by `NodeField::from_sub`.
    let sub = SubNodeField::from_poi1(&poi1_h, vec!["UX".into(), "UY".into()])?;
    // ... remplissage ...
    let u = NodeField::from_sub(sub);

    // Échelle auto, viridis, première composante, rendu interpolé niveau 4.
    mesh.plot_with_field(
        View::default(),
        Some(&dossier.join("ux.svg")),
        FieldArg::Node(&u),
        None,
        ColorScale::default(),
        4,
        None, // titre
    )?;

    // Component "UY", coolwarm colormap, bounds fixed at [-1, 1], flat.
    let scale = ColorScale {
        cmap: Colormap::CoolWarm,
        vmin: Some(-1.0),
        vmax: Some(1.0),
    };
    mesh.plot_with_field(
        View::default(),
        Some(&dossier.join("uy.svg")),
        FieldArg::Node(&u),
        Some("UY"),
        scale,
        0,
        None, // titre
    )?;
    Ok(())
}

Côté Python, cmap, vmin et vmax sont des arguments nommés de plot :

# Default component, viridis, automatic scale.
mesh.plot(save="t.svg", field=t_field)

# Composante "UY", colormap "coolwarm", bornes fixées.
mesh.plot(
    save="uy.svg",
    field=u_field,
    component="UY",
    cmap="coolwarm",
    vmin=-1.0,
    vmax=1.0,
)

# Ceiling only set: the floor follows the data's minimum.
mesh.plot(save="t.svg", field=t_field, vmax=100.0)

# Field at the Gauss points: strictly the same call.
mesh.plot(save="flux.svg", field=flux_field)

Bouton de sélection dans la fenêtre interactive

En mode interactif (viz-interactive), un bouton cliquable apparaît au sommet de la fenêtre, affichant la composante actuelle et son intervalle. Deux manières équivalentes d’en changer :

  • Clic sur le bouton — cycle dans l’ordre des composantes du champ ;
  • Touche Tab — même effet, sans toucher à la souris.

La caméra (rotation à la souris, molette, axes affichés via A) continue de fonctionner exactement comme en plot classique ; seul un clic sur le bouton est intercepté, les clics ailleurs lancent une rotation comme d’habitude.

Tracé d’une évolution

L’objet Evolution / SubEvolution expose plot(...), qui s’adapte au type de valeur tabulée :

  • évolution de scalaires → une courbe X-Y : l’abscisse est la variable, l’ordonnée la valeur. Un agrégat à plusieurs zones trace une ligne par zone avec légende ; chaque échantillon tabulé est marqué d’un point. Les libellés se règlent par x_label / y_label / title. Les arguments de champ (mesh, component, cmap, …) sont sans effet ici.
  • évolution de champs → le champ est rendu comme par mesh.plot(field=...), pour une valeur tabulée à la fois. La géométrie suit la même règle que le tracé d’un champ seul : champ par éléments → reconstruit son maillage via le support EF ; champ aux nœuds → nuage de points par défaut, ou surface si on passe mesh=<maillage>.
import pyrucast as pc

# Courbe scalaire (variable → valeur).
e = pc.Evolution([(0.0, 10.0), (1.0, 20.0), (2.0, 5.0)])
e.plot(save="courbe.svg", x_label="temps", y_label="T", title="évolution de T")

# Evolution of a field at the nodes: one whole NodeField per time step.
ev = pc.Evolution([(0.0, champ_t0), (1.0, champ_t1), (2.0, champ_t2)])
ev.plot(save="frame.png", frame=2)  # one tabulated value (default: the last)
ev.plot(save="frame_surf.png", mesh=maillage)  # surface rendering on a supplied mesh

Slider de valeur tabulée (fenêtre interactive)

En mode interactif (viz-interactive, save=None), une évolution de champs ouvre la fenêtre avec un slider dessiné en bas, qui choisit quelle valeur tabulée est affichée (le libellé indique frame k/n x=…) :

  • glisser le curseur du slider à la souris ;
  • touches ← / → pour reculer / avancer d’un pas tabulé.

Le bouton de composante (clic / Tab) et la caméra (rotation, molette, axes via A) fonctionnent comme d’habitude ; un clic sur le slider est intercepté, ailleurs c’est une rotation. Le slider ne choisit que parmi les valeurs tabulées — il n’interpole pas entre elles (pour une valeur intermédiaire, voir interpolate sur la page Évolution).

Toutes les sous-évolutions d’un agrégat tracé doivent partager la même grille d’abscisses (un index de frame global l’exige) ; sinon plot lève une erreur.

Types d’éléments rendus

Tous les types d’éléments sont rendus, chacun converti en une primitive géométrique :

TypePrimitiveRendu
POI1pointun point coloré
SEG2segmentune arête
TRI3facetriangle plein + contour noir
QUA4facequadrangle plein + contour noir
TET4faces triangulairesla peau du volume (facettes de bord)
HEX8faces quadrangulairesla peau du volume (facettes de bord)

Mesh.plot parcourt tous ses sous-maillages et dessine chacun selon son type. L’ajout d’un éventuel nouveau type d’élément se fera sans changement d’API, en étendant le match de submesh_primitives dans src/viz/mesh_draw.rs.

Maillages volumiques pleins

Pour un sous-maillage volumique (TET4 / HEX8), seules les facettes de bord sont dessinées : une facette partagée par deux éléments est intérieure au solide, jamais visible, et donc supprimée (une facette est de bord quand elle n’apparaît que dans un seul élément). Cela rend un maillage volumique plein comme une surface fermée opaque au lieu d’un enchevêtrement de toutes les facettes internes, et divise à peu près par deux le nombre de primitives.

Les facettes sont tracées opaques : combinées au tri en profondeur de l’algorithme du peintre, elles réalisent l’élimination des faces cachées (une facette proche recouvre intégralement celles derrière elle), si bien qu’on ne voit que la peau tournée vers la caméra. La suppression des faces intérieures s’applique aussi bien au tracé géométrique qu’à la coloration par un champ (plate et interpolée), de sorte qu’un champ sur un maillage volumique colore correctement sa surface externe.

Peau opaque ou fil de fer

Pour le tracé d’un maillage seul (sans champ), deux styles sont disponibles :

StyleRendu
Surface (défaut)la peau externe opaque ; l’intérieur est masqué
Wireframetoutes les arêtes en fil de fer (y compris les arêtes intérieures des volumes), sans remplissage — un tracé transparent

Le fil de fer trace chaque arête distincte (les arêtes partagées par plusieurs cellules ne sont dessinées qu’une fois) dans la face_color du sous-maillage, donc les composants d’un Mesh restent distinguables. Ce choix n’a pas de sens pour la coloration par un champ (un champ peint toujours les faces) : le combiner avec field lève une erreur.

Côté Rust, le style est un argument [MeshStyle] passé à plot_styled :

#[test]
fn peau_opaque_ou_fil_de_fer() -> Result<()> {
    let (dossier, _, mesh) = scene()?;

    // Peau opaque (équivalent de plot).
    mesh.plot_styled(
        View::iso(),
        Some(&dossier.join("solide.svg")),
        MeshStyle::Surface,
        None, // titre
    )?;
    // Wireframe: every edge.
    mesh.plot_styled(
        View::iso(),
        Some(&dossier.join("fil.svg")),
        MeshStyle::Wireframe,
        None, // titre
    )?;
    Ok(())
}

Côté Python, c’est l’argument booléen wireframe de plot :

mesh.plot(save="solide.svg")  # peau opaque (défaut)
mesh.plot(save="fil.svg", wireframe=True)  # fil de fer

# Pointless with a field: raises ValueError.
# mesh.plot(save="x.svg", field=t_field, wireframe=True)

Axisymétrie : section méridienne ou corps de révolution

Un maillage bâti sur des coordonnées axisymétriques est le demi-plan méridien (r, z) d’un corps de révolution : tracé tel quel, il se lit comme une section plane 2-D — ce qui est fidèle à l’objet calculé, mais peu parlant pour montrer la pièce.

L’option revolve balaie cette section autour de l’axe r = 0 et dessine le solide qu’elle décrit. Rien n’est recalculé : le balayage a lieu sur les primitives de rendu, juste avant la projection, donc il s’applique de la même façon au maillage seul, au fil de fer, à la coloration par un champ (plate et interpolée) et aux évolutions.

ArgumentDéfautEffet
revolveFalseTrue ⇒ trace le corps de révolution au lieu de la section
revolve_angle360.0angle balayé en degrés, dans ]0, 360]

Un angle partiel ouvre la pièce et dessine la section méridienne — et le champ qui la colore — aux deux extrémités du balayage, comme une coupe.

import pyrucast

coords = pyrucast.Coords.axisymmetric()  # (r, z), r ≥ 0
section = [coords.add_node(p) for p in ([1.0, 0.0], [2.0, 0.0], [1.0, 1.0])]
mesh = pyrucast.Mesh(coords, "TRI3")
mesh.unit().add_cell(section)
# … computation, then a temperature field at the section's nodes:
t_field = pyrucast.NodeField(pyrucast.mesh.poi1_from_nodes(section), ["T"])

mesh.plot(save="section.svg")  # la section plane (défaut)
mesh.plot(save="piece.svg", revolve=True)  # le corps de révolution complet
mesh.plot(save="coupe.svg", revolve=True, revolve_angle=270.0)  # opened to 270°
mesh.plot(save="t3d.svg", field=t_field, revolve=True)  # field on the body

Côté Rust, c’est le champ revolve de la View, portant un [Revolve] :

#[test]
fn le_corps_de_revolution_se_demande_dans_la_vue() -> Result<()> {
    // The mesh must be **axisymmetric**: it is its frame that gives
    // l'axe autour duquel le balayage tourne.
    let (dossier, mesh) = section_axisymetrique()?;

    let vue = View {
        revolve: Some(Revolve::full()),
        ..View::iso()
    };
    mesh.plot(vue, Some(&dossier.join("piece.svg")))?;

    // Partial sweep, or angular fineness picked by hand.
    let _ = Revolve::new(270.0).unwrap(); // un secteur par 10°
    let _ = Revolve::with_sectors(360.0, 72).unwrap(); // silhouette plus lisse
    Ok(())
}

Demander revolve sur une géométrie non axisymétrique est une erreur : l’abscisse n’y est pas un rayon, le balayage n’aurait aucun sens.

Ce qui est dessiné

Seul ce qui est visible est émis, l’algorithme du peintre faisant le reste :

  • une face balaie un anneau de matière. Seules les arêtes de bord de la section engendrent une surface latérale : une arête partagée par deux cellules reste enfouie dans la matière. C’est le pendant exact de la suppression des facettes intérieures des maillages volumiques ;
  • le contour d’élément du rendu interpolé suit la même règle, si bien que le quadrillage du maillage reste tracé sur la surface balayée ;
  • segments et points sont répétés à chaque station angulaire, et les cercles décrits par leurs extrémités sont ajoutés : c’est le fil de fer (resp. le nuage de nœuds) du maillage balayé ;
  • une arête posée sur l’axe (r = 0) ne balaie rien ; une arête qui le touche par une extrémité balaie un cône (triangles au lieu de quadrangles).

Bascule dans la fenêtre interactive

En mode interactif, sur une géométrie axisymétrique uniquement :

  • un bouton en haut à gauche indique l’état courant (2D section / 3D 360deg) et bascule au clic ;
  • touche R — même effet, sans toucher à la souris.

La caméra se recentre à chaque bascule : le corps balayé est centré sur l’axe, la section ne l’est pas, sans quoi la pièce sortirait du cadre.

Export vers ParaView (export_vtk)

Pour les maillages industriels — ou simplement pour exploiter les filtres de ParaView — export_vtk écrit un fichier VTK legacy (UNSTRUCTURED_GRID) que ParaView lit nativement. C’est l’opérateur d’export (src/ops/export), pendant « écriture » du lecteur read_gmsh.

import pyrucast

# Géométrie seule.
pyrucast.export.export_vtk(mesh, "maillage.vtk")

# Geometry + field at the nodes (POINT_DATA).
pyrucast.export.export_vtk(mesh, "solution.vtk", field=temperature)

# Geometry + field at the Gauss points (CELL_DATA): one value per cell = the
# intra-element mean of the cell's Gauss points.
pyrucast.export.export_vtk(mesh, "contraintes.vtk", field=stresses)
  • Chaque sous-maillage est écrit ; les types d’éléments se traduisent un pour un (POI1→VERTEX, SEG2→LINE, TRI3→TRIANGLE, QUA4→QUAD, TET4→TETRA, PYRA5→PYRAMID, PENTA6→WEDGE, HEX8→HEXAHEDRON, et leurs variantes quadratiques) et l’ordre local des nœuds coïncide déjà avec celui de VTK : la connectivité est copiée telle quelle.
  • Une Coords 2-D est complétée en 3-D avec z = 0.
  • Un NodeField donne un tableau SCALARS par composante aux points (valeur nodale, 0 là où le champ n’est pas défini) ; un ElementField donne un tableau par composante aux cellules. La valeur par cellule est la moyenne des points de Gauss de cette cellule (moyenne intra-élément uniquement — les discontinuités inter-éléments restent visibles). Le champ aux éléments doit couvrir toutes les cellules du maillage : il provient d’un espace bâti sur ce maillage.

L’écrivain VTK n’est qu’un formateur : la mise à plat (quels points, quelles cellules dans quel ordre, quelles valeurs) vient de to_arrays, la sortie que partagent tous les échanges (voir to_arrays). Les fichiers ASCII produits sont identiques, octet pour octet, à ceux de la première version.

Binaire

binary=True écrit les mêmes sections, les nombres en binaire brut gros-boutiste comme l’exige le format legacy : fichier bien plus petit, lecture bien plus rapide pour un gros maillage.

# Same file, numbers written raw (big-endian): smaller, faster to read.
pyrucast.export.export_vtk(mesh, "solution_bin.vtk", field=t1, binary=True)

Séries temporelles

Passée une Evolution de champs, export_vtk écrit une série : un fichier par valeur tabulée, nom_0000.vtk, nom_0001.vtk…, et un index nom.vtk.series (JSON) qui donne à chacun son temps, l’abscisse de l’évolution. ParaView ouvre l’index comme un seul jeu de données muni d’un curseur de temps. Le maillage n’est mis à plat qu’une fois pour tous les pas.

# An Evolution of fields: one file per time, plus an index for ParaView.
chauffe = pyrucast.Evolution([(0.0, t0), (30.0, t1)])
pyrucast.export.export_vtk(mesh, "chauffe.vtk.series", field=chauffe, binary=True)
# → chauffe_0000.vtk, chauffe_0001.vtk and chauffe.vtk.series:
#   open the .series in ParaView, the time slider plays the two steps.
# The steps themselves, as whole fields:
print(chauffe.shared_abscissas(), len(chauffe.frames()))  # [0.0, 30.0] 2

Côté Rust : ops::export::write_vtk_mesh, write_vtk_node_field, write_vtk_element_field (avec un VtkEncoding), write_vtk_series, et les variantes vtk_*_string qui rendent le texte ASCII sans toucher au disque.

Limites actuelles et évolutions possibles

  • VTK legacy uniquement. Pas de .vtu (XML) ni de compression. Évolution : un back-end .vtu, recommandé par ParaView.
  • Composantes en scalaires séparés. Chaque composante donne un tableau SCALARS distinct ; pas de regroupement en VECTORS/TENSORS. Un déplacement (ux, uy, uz) sort en trois scalaires plutôt qu’en un champ vectoriel directement « warpable » dans ParaView.
  • CELL_DATA = moyenne des points de Gauss. VTK ne connaît pas de donnée au point d’intégration. Pour garder les points de Gauss, passer par MED (to_medcoupling, ON_GAUSS_PT).
  • Un champ par fichier. Plusieurs champs simultanés dans un même fichier restent à faire.

Échanger avec gmsh et Salome (to_gmsh, to_medcoupling)

Les deux sens des échanges passent par le même format à plat. Pour la lecture, voir from_gmsh et from_medcoupling.

to_gmsh pousse maillages et champs dans la session gmsh en cours, comme un nouveau modèle : chaque clé devient un groupe physique, chaque champ une vue — NodeData pour un champ aux nœuds, ElementData (moyenne par maille) pour un champ aux éléments, un pas par valeur d’une Evolution. gmsh dessine 1, 3 ou 9 composantes : un champ d’un autre nombre donne une vue par composante.

# A new gmsh model: the meshes as physical groups, the fields as views.
pyrucast.export.to_gmsh({"piece": piece}, {"T": temperature}, model_name="calcul")
gmsh.write("calcul.msh")  # or gmsh.fltk.run() to look at it
gmsh.view.write(gmsh.view.getTags()[0], "temperature.pos")

# from_gmsh reads the views back: a NodeData view is a NodeField.
_, champs = pyrucast.mesh.from_gmsh(pyrucast.Coords(dim=3))
print(sorted(champs))  # ['T']

to_medcoupling construit un medcoupling.MEDFileData, qu’on écrit avec sa propre méthode write : chaque clé devient un groupe MED (un maillage POI1, un groupe de nœuds), un champ aux nœuds ON_NODES, un champ aux éléments ON_GAUSS_PT avec sa règle déclarée dans l’élément de référence MED (gauss=False : ON_CELLS, la moyenne par maille), un profil quand le champ ne couvre pas tout un niveau, une Evolution un pas de temps par valeur.

# Each key becomes a MED group; an Evolution gives one time step per value.
chauffe = pyrucast.Evolution([(0.0, froid), (60.0, temperature)])
donnees = pyrucast.export.to_medcoupling(
    {"plaque": plaque, "bas": bas},
    {"T": chauffe, "sxx": contraintes},  # sxx: ON_GAUSS_PT
    mesh_name="plaque",
)
donnees.write("plaque.med", 2)  # a medcoupling object: its own writer

Limite de medcoupling pour TRI6, PENTA15 et HEX20 aux points de Gauss. Les éléments de référence par défaut de medcoupling ne suivent pas, pour ces trois types, sa propre connectivité MED (le TRI6 répète un sommet, les deux autres rangent leurs nœuds milieux autrement), et son localisateur de points de Gauss n’en accepte pas d’autres. pyrucast déclare l’élément cohérent avec la connectivité écrite : le fichier est juste et se relit dans pyrucast, mais getLocalizationOfDiscr() de medcoupling le refuse. Les douze autres types sont localisés exactement par medcoupling.

Ni gmsh ni medcoupling ne sont des dépendances : import pyrucast ne charge aucun des deux, ils ne sont importés qu’à l’appel de ces fonctions.

Notes techniques

  • Le rendu utilise l’algorithme du peintre : projection 3D → 2D, tri des triangles par profondeur moyenne (du plus lointain au plus proche), puis dessin des facettes pleines opaques suivies des arêtes noires en superposition. L’opacité assure l’élimination des faces cachées (les facettes proches recouvrent les lointaines) ; c’est ce qui fait qu’un solide 3D se lit comme un solide et non comme une coque transparente. Coût : O(n log n) à chaque rafraîchissement, raisonnable jusqu’à quelques milliers de cellules. Pour des maillages plus lourds ou un post-traitement avancé, exporter vers ParaView avec export_vtk (voir ci-dessus).
  • Limite connue de l’algorithme du peintre : pour un solide fortement non convexe, le tri par profondeur moyenne peut mal ordonner deux facettes qui se chevauchent en profondeur. C’est inhérent à la méthode ; un z-buffer par pixel le corrigerait, au prix d’un rendu non vectoriel.
  • Le balayage axisymétrique (revolve) multiplie le nombre de primitives par le nombre de secteurs, mais seulement sur le bord de la section (les arêtes intérieures ne balaient rien) : le coût reste proportionnel au périmètre, pas à la surface. Une section partielle ajoute en plus une copie de la section à chaque extrémité.
  • L’export reste portable Linux ↔ Windows : tout le rendu se fait en CPU, sans pilote GPU. Le binaire viz-interactive nécessite en revanche un serveur d’affichage (X11, Wayland ou Windows) à l’exécution — ce qui est attendu pour une fenêtre interactive.
  • Le mode interactif est confiné à src/viz/window.rs ; il est entièrement encapsulé derrière la feature viz-interactive et ne s’invite pas dans la couche de calcul.

Mailler une géométrie

Cette page est un guide de choix, pas une référence : elle compare les mailleurs entre eux et dit lequel prendre. La description de chaque opérateur, ses arguments et ses pièges vivent dans Opérateurs → Maillage.

La partie 3D viendra plus tard ; seul le 2D est traité ici.

Mailler en 2D

Quatre opérateurs remplissent l’intérieur d’un contour fermé. Ils prennent tous la même chose — un ou plusieurs contours orientés en SEG2 et une taille de maille visée — et rendent tous un maillage dont le bord est le contour, nœuds compris.

opérateurméthodetypes et optionsrend
triangulate_surfaceDelaunay contraint + raffinement de Ruppert"TRI3", "QUA4" ; sizedes TRI3 — en QUA4, des quadrangles et quelques triangles
pave_surfacefront avançant, en rangées depuis le bord"QUA4", "QUA8", "QUA9" ; size, prefer_quads, relaxdes QUA4, quelques TRI3
grid_surfacecœur en grille + bande frontale au bord"QUA4", "QUA8", "QUA9" ; size, band, prefer_quads, relaxdes QUA4, quelques TRI3
grid_surface2cœur en grille, une ligne par nœud du contour, + bande frontale au bord"QUA4", "QUA8", "QUA9" ; size, band, prefer_quads, relaxdes QUA4, quelques TRI3

Le premier argument est le contour, le deuxième le type d’élément. Les options :

  • size — la taille de maille visée ; sans elle, la longueur moyenne des arêtes du contour, domaine par domaine ;
  • prefer_quads (défaut False) — True refuse une boucle au nombre impair de segments, qu’aucun pavage en quadrangles ne remplit, puis tient le front pair jusqu’au bout. Moins de triangles, mais pas aucun : la fermeture en pose encore là où un quadrangle serait retourné. C’est une préférence, pas une garantie ;
  • relax (défaut "free") — ce que le front s’autorise entre deux rangées : "free", "along" ou "none" (voir plus bas) ;
  • band (défaut 0) — une marge, en mailles, entre le cœur en grille et le contour ; zéro est la valeur utile, à relever seulement pour un contour que la grille ne peut pas rejoindre, comme une courbe.

Comment lire les figures

Chaque figure montre le même contour maillé quatre fois. Il est construit une seule fois, puis dupliqué par translate : aucun mailleur ne bénéficie d’une discrétisation de bord différente des autres. La disposition ne change jamais — en haut triangulate_surface et pave_surface, en bas grid_surface et grid_surface2. Le contour est en bleu et ses nœuds en rouge.

Les figures et les chiffres de cette page sortent d’un seul script, examples/comparer_mailleurs_2d.py.

La qualité citée est la mean ratio du pire coin : 1 pour un coin droit à côtés égaux, 0 pour un coin plat, négatif pour une maille retournée. C’est la même mesure pour les triangles et pour les quadrangles, ce qui est la seule façon de comparer les quatre sur un pied d’égalité. On donne aussi le 5ᵉ centile, qui dit ce que valent les mailles médiocres et non la seule plus mauvaise.

En pratique cette mesure ne descend jamais à zéro dans les tableaux qui suivent, et ce n’est pas une chance : les quatre mailleurs refusent de rendre un maillage portant une maille retournée ou plate. Une telle maille a un jacobien négatif ou nul, qu’aucun code éléments finis n’intègre — ce n’est pas un maillage médiocre mais un maillage faux, et mieux vaut une erreur qui situe la maille fautive. Un mailleur qui échoue là où un autre passe est donc un résultat en soi, à lire dans les tableaux comme tel.

Une forme rectilinéaire posée sur la grille

C’est le cas le plus favorable aux mailleurs en grille, et la différence est franche.

formetriangulatepavegridgrid2
rectangle 1 × 0,63mailles286966060
pire0,4250,3890,9990,999
plaque, marche sur la grillemailles3941248080
pire0,4890,6691,0001,000

Les deux mailleurs en grille rendent le maillage qu’on dessinerait à la main : toutes les mailles sont des rectangles et il n’y a pas un seul triangle. Le paveur, lui, rend une pelure d’oignon dont les rangées se referment sur une ligne médiane, avec deux petites zones de fermeture à ses bouts — sa faiblesse est là où ses rangées se referment, et cette fermeture existe même sur un rectangle.

Une forme rectilinéaire qui ne tombe pas sur la grille

Dès que les cotes ne sont plus des multiples de la taille visée, grid_surface et grid_surface2 se séparent nettement.

formetriangulatepavegridgrid2
plaque à marche (0,53 ; 0,61)mailles3661308680
pire0,4810,5710,4050,963
L à cotes quelconquesmailles3081117670
pire0,4590,6800,4370,979
L étiré à 1,02mailles3191078174
pire0,4770,6390,3070,606
L étiré à 1,10mailles3361128074
pire0,4740,6490,4210,963

grid_surface pose une ligne sur la coordonnée où repose chaque côté aligné, puis découpe entre deux lignes d’après le côté qui les enjambe : toutes ses lignes sont droites. grid_surface2 donne à chaque nœud du contour la ligne qui le traverse et laisse ses rangées plier pour aller chercher le contour — une même rangée peut alors rejoindre deux parois qui se font face à deux hauteurs différentes, ce qu’aucune droite ne sait faire.

Le L étiré à 1,02 est le seul de la série que grid_surface2 ne règle pas — le paveur y fait mieux : les deux côtés y posent un nombre de nœuds différent sur la même portée, dix contre onze, et aucune disposition de lignes n’invente la rangée manquante. Le remède est dans le contour, pas dans le mailleur — voir la règle de discrétisation.

Un profil à sept angles rentrants

Le profil crénelé est décliné en deux versions dont seule la base change : coupée sous chaque barre, ou d’un seul tenant. C’est le meilleur révélateur de la sensibilité d’un mailleur à la discrétisation du contour.

formetriangulatepavegridgrid2
base coupéemailles2 050816456456
pire0,4120,1180,8600,916
base d’un seul tenantmailles2 089827471456
pire0,4190,4150,2870,651

grid_surface2 rend exactement le même nombre de mailles dans les deux cas. Les trois autres paient la base d’un seul tenant. C’est la propriété la plus utile de ce mailleur : il pardonne une discrétisation de contour que les autres font payer.

Une forme oblique ou courbe

Ici le classement s’inverse, et c’est le seul endroit où il le fait.

formetriangulatepavegridgrid2
maisonmailles1 723611447450
pire0,4840,5150,3650,548
carré arrondimailles1 866665418404
pire0,4740,3600,3400,467
cercle R = 1mailles1 496547312530
pire0,5270,0810,1790,444

Une grille ne peut pas suivre une oblique : elle la découpe en escalier, et tout ce qui s’en approche est rendu au paveur frontal. Sur la maison, où l’oblique ne fait que le toit, ce qui reste de rectilinéaire suffit encore à grid_surface2 ; sur le cercle, qui n’a plus rien de droit, triangulate_surface est le meilleur des quatre.

Sur une forme sans direction dominante, grid_surface2 n’est pas un candidat. Il prend l’écartement de ses lignes sur le contour, et un cercle n’en dicte aucun : selon la taille demandée, son cœur tient ou s’effondre. À la taille de la figure il rend 0,444, mais la taille voisine le fait tomber au bord de la dégénérescence. Pire maille du même cercle, selon la taille :

tailletriangulatepavegridgrid2
0,050,4240,0400,3660,011
0,060,5260,0310,2910,406
0,070,4240,0150,1370,012
0,080,4360,0390,1910,035
0,10,5270,0810,1790,444
0,120,4970,0030,3620,005

triangulate_surface ne descend jamais sous 0,42 et grid_surface jamais sous 0,13 ; les deux autres passent d’une maille correcte à une maille presque plate d’une taille à la voisine. Sur une courbe, prenez triangulate_surface si la qualité du pire élément commande, grid_surface s’il faut des quadrangles.

Le cercle mérite un mot de plus. pave_surface y tombe à 0,081 — ses rangées se referment toutes au centre, et c’est cette fermeture qui porte tout le défaut. triangulate_surface, lui, ne descend jamais sous 0,41, sur aucune des onze formes : c’est la signature d’un Delaunay raffiné, qui garantit un angle minimal et rien de plus. Il ne s’effondre jamais, mais il ne monte jamais non plus.

Ce qu’il faut retenir

Le coût en mailles est le classement le plus stable : sur les dix formes rectilinéaires ou peu obliques, les deux mailleurs en grille rendent le moins de mailles — grid_surface2 le plus souvent, grid_surface sur la maison, à trois mailles près —, puis pave_surface, puis triangulate_surface, avec un facteur quatre à cinq entre les extrêmes, à qualité égale ou meilleure (à un centième près sur le carré arrondi). Pour un calcul, c’est le facteur qui compte juste après la qualité. Le cercle est la seule exception : grid_surface2 y rend plus de mailles que grid_surface (530 contre 312), pour une qualité qui s’effondre d’une taille à l’autre — la façon la plus nette de dire qu’il n’est pas fait pour ça.

En pratique :

  • forme rectilinéaire — grid_surface2, et d’autant plus si ses côtés n’ont pas été coupés aux angles qui leur font face ;
  • forme courbe — triangulate_surface si la qualité du pire élément commande, grid_surface s’il faut des quadrangles ; jamais grid_surface2, qui y perd son cœur à la plupart des tailles ;
  • forme franchement oblique — pave_surface, dont les rangées épousent la pente ; mais tant qu’il reste des parois droites autour de l’oblique, les mailleurs en grille tiennent — la maison en est l’exemple ;
  • le moins de triangles possible — prefer_quads=True, sur les trois paveurs, refuse par une erreur claire un contour dont la parité l’interdit ; quelques triangles peuvent encore rester là où un quadrangle serait retourné ;
  • triangles voulus — triangulate_surface, seul à en produire par construction.

Le front peut garder ses angles : relax

Les chiffres de pave_surface ci-dessus sont ceux du réglage par défaut. Entre deux rangées, le front est relaxé — c’est ce qui l’empêche de se plisser — et cette relaxation est un laplacien, qui arrondit les angles. Or un front ne perd des nœuds qu’à ses coins : une fois les coins arrondis, il garde tous ses nœuds pendant que son périmètre rétrécit, et le milieu du domaine sort plus fin que la taille demandée. Un carré 20 × 20 à la taille 1 sort à 592 mailles au lieu de 400, les plus intérieures à 0,26 de l’aire visée.

relax="along" garde le même déplacement mais projeté sur le front : l’écartement s’égalise encore, la forme n’est plus rabotée, et le carré sort comme les 400 carrés exacts qu’on dessinerait. relax="none" ne relaxe pas du tout. Aucun des trois ne gagne partout. Sur les formes à angles, "along" et "none" l’emportent nettement ; sur une courbe, il n’y a pas d’angle à préserver, et aucun réglage ne protège le paveur — sur le cercle, chacun des trois passe devant ou tombe près de zéro selon la taille. Mesuré sur un carré et une bande à la taille 1, puis sur trois formes des figures ci-dessus, à leur taille :

forme"free""along""none"
carré 20 × 20592 mailles, pire 0,619400, 1,000400, 1,000
bande étroite 40 × 3123, 0,702120, 1,000123, 0,684
L à cotes quelconques111, 0,68066, 0,78066, 0,774
créneau, base coupée816, 0,118586, 0,426577, 0,546
cercle R = 1547, 0,081610, 0,281620, 0,027

Le détail du mécanisme est sur la page Mailler des opérateurs. Les deux mailleurs en grille prennent le même réglage, qui n’y gouverne que la bande : la grille est posée droite quoi qu’il arrive.

Deux limites connues

Un vide que le contour ne borde pas n’est pas maillé en grille. grid_surface2 prend l’écartement de ses lignes sur le contour ; là où le contour ne dit rien — le triangle du toit de la maison, qu’aucune paroi verticale ne borde — il coupe le vide en mailles entières tant qu’il ne dépasse pas trois fois l’écartement moyen, et l’abandonne au front au-delà. Remplir un tel vide de rangées ne crée pas du cœur : ces rangées n’existent que pour être érodées par l’oblique qui les traverse, et elles morcellent le travail du front au lieu de lui donner une région propre. Sur la maison, l’abandon ne laisse qu’un triangle (5ᵉ centile 0,725) ; quand ce seuil a été posé, le remplissage en coûtait cinq.

C’est aussi ce qui coûte le cercle, dont le contour laisse de grands vides. Aucun seuil ne sépare les deux cas — abandonner un vide abandonne a fortiori un vide plus grand — et c’est un choix assumé : grid_surface2 sert les formes rectilinéaires, grid_surface sert les courbes.

Le cercle est un objectif instable. Sur une forme sans direction dominante, un rien déplace les décisions des mailleurs : le tableau des tailles le montre, et la position compte aussi — le même cercle, maillé à l’origine au lieu de sa place dans la figure, sort de grid_surface2 à 0,459 au lieu de 0,444. Les chiffres du cercle ci-dessus sont justes mais ne se prolongent pas : ne réglez aucun paramètre dessus.

Reproduire les figures

maturin develop --features extension-module,viz
python examples/comparer_mailleurs_2d.py

# Figures du livre :
PYRUCAST_IMG_DIR=book/src/img python examples/comparer_mailleurs_2d.py

Le script tient dans une fonction : elle prend un contour, le duplique trois fois par translate, maille chaque copie par une méthode et réunit le tout sur une figure.

def comparer(nom, contour, taille, fichier, noeuds=True):
    """Maille `contour` par les quatre méthodes et trace la figure.
    / Meshes `contour` with all four methods and draws the figure.

    FR — Le contour n'est construit qu'une fois : ses trois copies viennent de
    `translate`, qui rend un maillage neuf dans les mêmes `Coords` — c'est ce
    qui permet de les réunir sur une seule figure.
    """
    points = [n.position() for sub in contour for cell in sub for n in cell]
    xs, ys = [p[0] for p in points], [p[1] for p in points]
    dx, dy = (max(xs) - min(xs)) * 1.15, (max(ys) - min(ys)) * 1.25
    coins = [(0.0, 0.0), (dx, 0.0), (0.0, -dy), (dx, -dy)]

    tout, lignes = None, []
    for (titre, mailler), coin in zip(MAILLEURS, coins):
        copie = pc.mesh.translate(contour, list(coin))
        maillage = mailler(copie, taille)
        qs = sorted(
            qualite([n.position() for n in cell]) for sub in maillage for cell in sub
        )
        comptes = dict(zip(maillage.element_types(), maillage.cell_counts()))
        lignes.append(
            f"{titre:20s} {len(qs):6d} mailles, {comptes.get('TRI3', 0):5d} tri, "
            f"pire {qs[0]:.3f}, p5 {qs[len(qs) // 20]:.3f}"
        )
        for sub in copie:
            sub.face_color = (20, 90, 200)
        bloc = maillage | copie
        if noeuds:
            points_rouges = pc.mesh.to_poi1(copie)
            for sub in points_rouges:
                sub.face_color = (220, 30, 30)
            bloc = bloc | points_rouges
        tout = bloc if tout is None else tout | bloc

    print(f"\n{nom}   (taille visée {taille:g})")
    for ligne in lignes:
        print(f"   {ligne}")
    tout.plot(
        view=VUE,
        show_axes=False,
        save=os.path.join(OUT, fichier),
        title=f"{nom} — h={taille:g} — haut : triangulate, pave — bas : grid, grid2",
    )


Mailler en 3D

À venir. En attendant, les mailleurs volumiques — triangulate_volume (tétraèdres), pave_volume (couche limite d’hexaèdres autour d’un cœur tétraédrique), extrude et sweep_solid — sont décrits dans Opérateurs → Maillage.

Calcul pas à pas (pyrucast.stepping)

pyrucast.stepping reproduit la procédure PASAPAS de Cast3M et celles qu’elle appelle, une fonction Python par procédure : boucle en temps, thermique (trois schémas, rayonnement, échange), mécanique non linéaire (Newton modifié accéléré, sous-pas de convergence forcée, prédicteur), contact, dynamique (Newmark), pilotages automatique et indirect, pas adaptatif, crochets utilisateur. Le package est du Python pur, mais tout calcul sur un champ passe par un opérateur pyrucast : seuls les trois scalaires de l’accélération d’Anderson sont calculés en Python.

Utilisation

On décrit le calcul par un dictionnaire, comme la table de PASAPAS, puis on le lance :

# The left edge goes from 20 to 220 °C; the modulus halves on the way.
materiaux = pc.element_field.material_field(
    modele,
    [
        ("k", 1.0),
        ("rho", 1.0),
        ("cp", 1.0),
        ("E", 200e9),
        ("nu", 0.3),
        ("alpha", 1.2e-5),
        ("sigma_y", 250e6),
    ],
)
module = pc.Evolution(
    [(20.0, 200e9), (220.0, 100e9)],
    abscissa_type="T",
    ordinate_type="E",
    out_of_range="extrapolate",
)
data = {
    "model": modele,  # heat + mechanics + supports
    "times": [0.0, 100.0, 200.0, 400.0],  # the instants computed
    "materials": materiaux,  # a field, or an Evolution of fields
    "material_laws": module,  # E(T), applied to the temperature
    "loads": pc.Evolution(
        [(0.0, _temperature_imposee(20.0)), (100.0, _temperature_imposee(220.0))],
        out_of_range="clamp",
    ),
    "initial": {"temperature": pc.NodeField(maillage, ["T"]) + 20.0},
    "t_ref": 20.0,  # the stress-free temperature
}
pc.stepping.step_by_step(data)
for record in data["results"]:
    print(record["time"], record["iterations"], record["converged"])

Les résultats sont rangés dans data["results"], un enregistrement par instant de save_times (par défaut times), l’état initial compris :

dernier = data["results"][-1]
dernier["temperature"]  # T at the nodes
dernier["displacement"]  # u, multipliers left out
dernier["reactions"]  # the forces the supports exert, −Cᵀλ
dernier["behaviour"]  # stresses, internal variables, strains at the Gauss points

Chaque enregistrement porte aussi velocity et acceleration (dynamique), load_multiplier (pilotage indirect), iterations, accelerations et converged. Il garde l’état calculé, pas ce qui s’en déduit : en grands déplacements, behaviour contient la contrainte qu’écrit la loi (S_* ou T_*), et la contrainte de Cauchy se calcule à partir de displacement et behaviour, par deformation(…, "gradient") puis stress_measure (voir Grandes transformations).

Les entrées

clérôleCast3M
modelmodèle complet : physiques, contraintes, charges portéesMODELE
timesinstants calculésTEMPS_CALCULES
save_timesinstants enregistrés (défaut : times)TEMPS_SAUVES
materialschamp matériau, ou Evolution de champs dans le tempsCARACTERISTIQUES
material_lawslois scalaires de l’état, E(T), σ_y(T, φ)… (Évolution)VARI
loadscharges nodales et valeurs imposées, champ ou EvolutionCHARGEMENT
temperaturetempérature imposée, sans modèle thermiqueCHARGEMENT 'T'
initialdisplacement, behaviour, temperature, velocity, acceleration ; ce qui manque part du repos : déplacement et mouvement nuls, l’état de repos des lois (element_field.initial_state), température uniforme t_refindice 0 des tables
t_reftempérature sans dilatationTEMPERATURE_REFERENCE
hooksprocédures utilisateur, par nom (HOOKS)REEV_MEC, PERSO1, AUTOPILO…

Les réglages et leurs valeurs par défaut sont ceux de PAS_DEFA : precision et strain_precision (1e-4), max_iterations (49, 250 en convergence forcée ou piloté), delta_iterations (10), max_substeps (100), forced_convergence (vrai), relaxation (1), acceleration (vrai), tangent et tangent_every, predictor (vrai), adaptive_step, dynamics, heat_scheme (nonlinear, linear, dupont), theta (1), dupont_relaxation (¼), coherence et coherence_passes (10), automatic, automatic_target (1e-3), indirect_control ({"load": F_p, "increment": Δτ}), augmented_stiffness, geometric_stiffness (vrai en grands déplacements), trace. Une fonctionnalité absente du package (non local, MAN, FEFP, frottement, rayonnement de cavité, changement de phase, modèle évolutif, THM, advection) est refusée avec sa raison, jamais ignorée.

L’algorithme

Les numéros d’algorithme suivent l’analyse de PASAPAS.

Algo 1 — la boucle en temps (step_by_step)

options ← resolve_options(data) ; problème ← describe_problem ; état ← initial_state
pour chaque instant cible TI de times
  pas adaptatif : instant ← adjust_time_step (Δt/√2 au-delà de 20 itérations,
                  Δt·√2 en deçà de 7, Δt/2 après un échec, sans dépasser TI)
  pour chaque passe (une ; jusqu'à coherence_passes avec coherence)
    thermique jusqu'à l'instant                                  // Algo 2
    mécanique jusqu'à l'instant, à la température atteinte       // Algo 3
    coherence : sortir si T n'a plus bougé d'une passe à l'autre
  échec : pas adaptatif → rejouer, Δt divisé par 2 ; sinon erreur
  t ← instant (pilotage automatique : t ← t + α (instant − t), et rejouer TI)
  store_step ; crochet after_step

Algo 2 — la thermique (heat_step, heat.heat_step_linear, heat.heat_step_dupont)

L’inconnue porte la température et les multiplicateurs des températures imposées. Le schéma θ itéré (TRANSNON) résout

\[ \Bigl(\theta K(T_n) + \tfrac{C}{\Delta t}\Bigr)\,\delta T = F - F_{int}(T_\theta) - \tfrac{C}{\Delta t}(T - T_n), \qquad T_\theta = T_n + \theta (T - T_n), \]

l’opérateur étant figé à T_n (la tangente : conduction, et 4σεT³ pour le rayonnement), le résidu exact, les matériaux réévalués à T_θ. Arrêt quand la correction est sous precision relativement à la température ; accélération d’Anderson non pondérée aux itérations paires à partir de la quatrième. Sans cp, le problème est stationnaire (θ = 1). linear fait une seule résolution, matériaux du début ; dupont résout le schéma à trois niveaux

\[ \Bigl(\tfrac{C}{\Delta t} + (\tfrac12+\lambda)K\Bigr) T_{n+1} = \Bigl(\tfrac{C}{\Delta t} + (2\lambda-\tfrac12)K\Bigr) T_n - \lambda K T_{n-1} + F , \]

linéarisé à la température extrapolée au milieu du pas.

Algo 3 — le pas mécanique (mechanical_step)

L’inconnue x porte le déplacement et les multiplicateurs des contraintes. Chaque résolution rend une correction de déplacement et les multiplicateurs en total, dont le solveur unilatéral teste le signe ; la réaction est −Cᵀλ :

\[ \begin{bmatrix} K & C^\mathsf{T} \\ C & 0 \end{bmatrix} \begin{bmatrix} \delta u \\ \lambda \end{bmatrix} = \begin{bmatrix} F_{ext} - F_{int}(u) \\ g - C u \end{bmatrix}, \qquad r = F_{ext} - F_{int}(u) - C^\mathsf{T}\lambda . \]

pour chaque sous-pas (max_substeps au plus)
  données du pas à l'instant visé : matériaux, charges, ε_th = α (T − T_ref)
  K ← iteration_operator : rigidité élastique gardée tant que le matériau ne
      change pas (tangente cohérente avec `tangent`, + 4/Δt² M en dynamique)
  R⁰ au début du pas ; δx⁰ = K⁻¹R⁰ ; F_ref ← reference_forces (1er sous-pas)
  prédicteur : R⁰ += a·F_NL du pas précédent (nonlinear_predictor)
  equilibrium_iterations                                         // Algo 4
  convergé : fin ; sinon (convergence forcée) l'itéré atteint devient le
  départ d'un nouveau sous-pas, vers une cible rapprochée si relaxation < 1

F_ref = |δx⁰·(F_ext ; −g)| / ‖δu⁰‖∞ + 2 F_s, borné par la plus grande force appliquée (F_s : la plus grande force interne au début du pas). F_NL = K Δx − ΔR⁰ + r mesure ce que l’opérateur n’a pas prévu ; le pas suivant en reprend la part a, rapport des incréments de charge projetés, borné par Δt/Δt_prec.

Algo 4 — les itérations d’équilibre (mechanics.equilibrium_iterations)

pour i = 1, 2, …
  tangent : K ← tangente à l'itéré, toutes les tangent_every itérations
  i > 3 et pas de correction à l'itération précédente :
      R ← R − correction d'Anderson                              // ACT3
  (δu, λ) ← K⁻¹R (unilatéral) ; u ← u + δu
  pilotage : rééchelonner δu et le niveau de charge
  lois intégrées à u                                             // Algo 5
  R, r, réactions ← bilan ; tests de convergence_status          // Algo 6

L’accélération (anderson.anderson_correction, mémoire 3) minimise ‖F₀ + Σ xⱼ (Fⱼ − F₀)‖ sur les quatre derniers résidus pondérés par (Σ mesures des mailles autour du nœud)^-½ ; elle recule à deux directions, une, aucune, comme ACT3, et une correction qui reviendrait en arrière le long du dernier incrément est abandonnée.

Algo 5 — les lois (mechanics.integrate_increment)

La déformation que lit chaque loi vient de l’opérateur strain du modèle, moins la dilatation thermique ; les lois avancent depuis l’état convergé du début du sous-pas (integrate_behavior). Toutes calculent leur contrainte à partir de leur déformation élastique avec le matériau de fin de pas (voir comportement) : un module qui dépend de la température ne demande donc aucune correction, là où Cast3M ajoute (D⁻¹ₙ₊₁ − D⁻¹ₙ)σₙ à l’incrément.

Algo 6 — les critères (mechanics.convergence_status)

Convergé quand le résidu des forces — et la variation des réactions — sous F_ref est sous precision, la variation de déformation de l’itération sous strain_precision, et le résidu des moments sous M_ref sous la précision des moments. Non-convergence détectée quand le résidu n’a pas baissé de 1 % en delta_iterations itérations, ou au plafond d’itérations : le plafond triple, et l’échec est déclaré quand la déformation ne bouge plus. Rien n’est détecté sous pilotage.

Dynamique, contact, pilotages

  • Newmark (γ = ½, β = ¼) : (K + 4/Δt² M) δu = F − F_int − M a(u), avec a = 4/Δt² (u − uₙ) − 4/Δt vₙ − aₙ ; il conserve l’énergie d’une vibration libre linéaire.
  • Contact : une contrainte d’inégalité (sense=">=" ou Model.contact) est résolue par le solveur à statuts à chaque itération ; les multiplicateurs en total en portent le signe. Le jeu d’un contact est une charge qu’il porte : le pas à pas le reçoit avec les autres charges du modèle.
  • Automatique (AUTOPILO) : Δu est mis à l’échelle pour que sa déformation vaille automatic_target, et la fraction de charge α ← α − (r·Δu)/(R·Δu) ; le temps n’avance que de α. Le crochet automatic_measure(data, state, Δu, αΔu_él) remplace cette taille, comme la redéfinition d’AUTOPILO : une taille négative retourne Δu. Une grandeur choisie pour croître — la flèche d’un sommet — fait ainsi franchir les points limites, où la réponse de l’opérateur change de sens ; la déformation, toujours positive, ne le peut pas (Cast3M non plus, dont le cas snap.dgibi redéfinit AUTOPILO de la même façon).
  • Indirect (PILOINDI) : F = F_ext + η F_p, η choisi pour que la déformation avance de Δτ dans sa propre direction au point le plus sollicité.

Grands déplacements

Il n’y a pas de clé à activer : les lois mécaniques construites avec strain_measure="green_lagrange" ou "hencky" déclarent leur mesure, et describe_problem la lit. Le calcul suit alors la voie du lagrangien total (cf. Grandes transformations) :

describe_problem : mesure lue sur les lois ; jeu « current » du Coords (nommé
                   « stepping:current »), ajouté au premier calcul, retrouvé ensuite
pour chaque itéré u :
  E ← strain(u) ; S ← loi(E)                     sur la référence
  P ← stress_measure(S, F(u), "pk1")
  r ← F_ext − internal_forces(P) − Cᵀλ           sur la référence
      + pression suiveuse évaluée en x = X + u   sur « current »
opérateur : stiffness + geometric(σ)              sur « current », en x = X + u
  + 4/Δt² M en dynamique                         sur la référence (ρ₀ dV₀)
  construit en début de sous-pas, puis à la 2ᵉ itération,
  puis toutes les max(delta_iterations, 20) itérations ; au-delà de 20, échec
contact : mis à jour en X + u à chaque construction de l'opérateur
          (model.update_contact) ; figé entre deux ; à convergence, mis à
          jour au dernier itéré : l'équilibre doit tenir avec lui, sinon
          l'opérateur est reconstruit là et l'on continue
thermique : conduction, échanges, rayonnement et flux sur « current »,
            en x = X + uₙ (début de pas), axes matériau tournés par R ;
            capacité sur la référence (ρ₀ c dV₀) ; l'opérateur additionne
            les matrices des deux
résultats : "behaviour" garde S (ou T) ; σ, à la demande, par stress_measure

L’opérateur est celui de Cast3M, K_e(GEOM2) + K_σ : il ne règle que la vitesse de convergence, la solution étant fixée par le résidu. Il est reconstruit dès la deuxième itération, la première correction ayant déplacé la géométrie (UNPAS reconstruit aussi à i = 2). geometric_stiffness=False le retire ; demandé en petits déplacements, il est refusé.

La conductivité donnée est celle de la matière telle qu’elle est : la thermique se calcule sur la pièce déformée, les axes d’une conduction orientée tournant avec elle (rotate_frame, rotation R de F = R U), et le flux de la loi est le flux réel. Seule la capacité reste sur la référence, où elle est exacte (cf. Grandes transformations).

Le contact suit le glissement du point projeté. Chaque fois que l’opérateur est reconstruit, ses contacts sont mis à jour à l’itéré (update_contact, le RFCO de Cast3M) : facette, poids et normale de X + u, mêmes multiplicateurs, et le jeu de ce nouvel appariement mesuré sur X. Entre deux reconstructions, les relations restent celles de l’opérateur : la résolution rend les multiplicateurs en total, contre les relations de l’opérateur, et un résidu écrit avec d’autres ne s’annulerait pas. À convergence, le contact est mis à jour une dernière fois au dernier itéré, et l’équilibre recalculé avec lui doit passer le même test de convergence (même mesure, même référence, même seuil) ; sinon l’opérateur est reconstruit là, l’accélération marque une pause, la fenêtre de stagnation repart, et les itérations continuent. Cast3M compare plutôt la variation des réactions, max|ΔR|²/max|R|², à la précision ; pyrucast garde le test qui décide déjà de la convergence.

Une maille retournée arrête le pas. Après chaque résolution, le pas à pas mesure J = det F (deformation(u, fes, "jacobian")) sur les zones qui remplissent l’espace — une barre ou une coque n’a pas de volume à perdre. Si J ≤ 0 en un point, l’itéré est rejeté : aucune loi n’y a de sens, et aucun sous-pas ne doit partir de là. Le pas échoue donc, depuis le dernier itéré encore à l’endroit ; avec adaptive_step, il est repris plus court, sinon le calcul s’arrête en le disant. Rien ne le verrait sinon : la mesure d’intégration est |det J|, et C = FᵀF reste défini positif quand F change de signe. En petits déplacements, une cinématique linéaire ne retourne pas une maille : rien n’est vérifié. Côté Cast3M, UNPAS ne teste pas le signe du jacobien ; seules certaines routines d’éléments le signalent.

Sont refusés en grands déplacements : une loi de petites déformations à côté d’une loi en grandes transformations (poutres et coques n’ont pas encore de formulation en grandes transformations ; une barre lit green_lagrange, cf. Les barres et les câbles).

Écrire sa propre boucle

Chaque procédure s’appelle seule ; step_by_step n’est que leur enchaînement :

# The same computation, one procedure at a time: what step_by_step does.
options = pc.stepping.resolve_options(data)  # PAS_DEFA
probleme = pc.stepping.describe_problem(data, options)  # PAS_MODL
etat = pc.stepping.initial_state(data, probleme, options)  # PAS_INIT
instant, _ = pc.stepping.adjust_time_step(etat, 100.0, None)  # PAS_AJUSTE
chaleur = pc.stepping.heat_step(data, probleme, etat, instant, options)  # TRANSNON
meca = pc.stepping.mechanical_step(  # UNPAS
    data, probleme, etat, instant, chaleur.temperature, options
)
materiaux_fin = pc.stepping.materials_at(probleme, chaleur.temperature, instant)

Correspondance avec Cast3M

Cast3Mpyrucast
PASAPASstepping.step_by_step
PAS_DEFAstepping.resolve_options
PAS_MODLstepping.describe_problem
PAS_INITstepping.initial_state
PAS_ETAT, PAS_MATEstepping.materials_at
PAS_RESUstepping.store_step
PAS_AJUSTEstepping.adjust_time_step
TRANSNON, TRANSLIN, DUPONT2stepping.heat_step, stepping.heat.heat_step_linear, stepping.heat.heat_step_dupont
UNPASstepping.mechanical_step
opérateur initial, F_ref, F_NLstepping.mechanics.iteration_operator, reference_forces, nonlinear_predictor
itérations, COMP, critèresstepping.mechanics.equilibrium_iterations, integrate_increment, convergence_status
ACT3stepping.anderson.anderson_correction
AUTOPILO, PILOINDIstepping.control.automatic_control (ou le crochet automatic_measure), indirect_control
REEV_MEC, REEV_THE, PERSO1, CHARMECA, CHARTHER, PARATHERcrochets after_mechanics, after_heat, after_step, extra_mechanical_load, extra_heat_load, heat_material_update
GRANDS_DEPLACEMENTS, LAGRANGIENla mesure des lois (strain_measure) ; toujours le lagrangien total
K_SIGMAgeometric_stiffness
FORM (GEOM2)stepping.configuration.Configurations
RFCOmodel.update_contact, appelé par stepping.mechanics.mechanics_at à chaque reconstruction de l’opérateur

Écarts assumés

  • Lois sécantes, sans correction de module (ci-dessus).
  • Dupont à trois niveaux d’un pas à l’autre : T_{n−1} est la vraie température précédente ; DUPONT2 dégénère en θ = ¾ entre deux pas.
  • Rayonnement : l’opérateur est la tangente 4σεT³ à T_n plutôt que la sécante σε(T² + T∞²)(T + T∞) évaluée aux nœuds ; le résidu est le flux σε(T⁴ − T∞⁴) calculé aux points de Gauss, et le schéma linear linéarise le rayonnement au lieu de l’ignorer. Les deux méthodes, et ce qui les sépare, sont décrites dans Rayonnement.
  • Un pas adaptatif raté n’est pas gardé : il est rejoué, réduit, depuis le dernier état convergé.
  • La température initiale vaut t_ref quand elle n’est pas donnée.
  • La tangente (tangent) est la tangente cohérente de la loi, là où KTAN donne la tangente continue.
  • La rigidité augmentée est une matrice donnée (augmented_stiffness) ; l’augmentation automatique n’est pas offerte.
  • Grands déplacements en lagrangien total, pour toute loi : ni Δε de mi-pas (GEOMM) ni transport des contraintes, l’état restant sur la référence. Le prédicteur HPP n’est pas repris.
  • Pas de prédicteur F_NL en grands déplacements : une rotation finie n’est pas dans le noyau de l’opérateur, si bien que K Δx y lit des efforts là où rien ne se déforme, et l’opérateur change d’un pas à l’autre.
  • Les axes d’une conduction orientée en grands déplacements tournent de la rotation R de F = R U. Cast3M les range dans le repère local de chaque élément, recalculé sur la géométrie courante : les deux s’accordent sous une rotation rigide, pas sous un cisaillement. La capacité est calculée sur la référence, ρ₀ cp dV₀, où elle est exacte.

Premiers pas

Ce tutoriel accompagne pas à pas un premier contact avec pyrucast : l’installer, lancer un calcul, puis mailler une pièce et y résoudre un problème thermique, en comprenant chaque ligne. Il suppose quelques notions sur la méthode des éléments finis, mais pas de pratique de Python : la page Python & conventions pyrucast en donne tout ce qu’il faut.

Le fil rouge des pages Maillage et Thermique linéaire est une chape percée — une plaque terminée par un demi-disque, trouée en son centre — que l’on maille, puis que l’on chauffe :

Installer pyrucast

pyrucast s’installe par pip, l’outil qui installe les paquets Python. Les commandes de cette page se tapent dans un terminal : PowerShell sous Windows, l’application Terminal sous macOS et Linux. Il faut Python 3.11 ou plus récent (python.org). Une seule commande :

pip install pyrucast

Sous Linux et macOS, les commandes s’appellent souvent python3 et pip3 : remplacez-les dans toute la suite si python est introuvable.

C’est tout : rien à compiler. Cette version, dite précompilée, contient le calcul et la visualisation (fenêtre interactive et export d’images). Elle existe pour Linux (processeurs x86_64), Windows 64 bits et macOS (Intel et Apple Silicon) ; pour les autres cas, voir Installation et démarrage rapide.

Pour passer plus tard à une version plus récente :

pip install --upgrade pyrucast

Un environnement à part. Pour garder pyrucast séparé des autres paquets Python de la machine, on peut l’installer dans un environnement virtuel, un dossier qui contient son propre Python :

python -m venv .venv
source .venv/bin/activate      # Linux, macOS
.\.venv\Scripts\Activate.ps1   # Windows (PowerShell)
pip install pyrucast

Il faut alors réactiver l’environnement (deuxième ou troisième ligne) dans chaque nouveau terminal.

Vérifier l’installation

python -c "import pyrucast; print(pyrucast.__version__)"

La commande affiche le numéro de la version installée. Un message ModuleNotFoundError: No module named 'pyrucast' veut dire que pyrucast n’est pas installé pour ce Python-là — par exemple parce que l’environnement virtuel n’est pas activé.

Un premier calcul

Le plus petit calcul complet : une plaque carrée d’acier de 10 cm de côté, chauffée uniformément dans toute sa masse, dont le bord est tenu à 0 °C. Où est-elle la plus chaude, et à quelle température ?

Le script tient en cinq gestes, les mêmes pour tout calcul. D’abord les données :

COTE = 0.10  # m, côté de la plaque / plate side
N = 20  # éléments par côté / elements per side
K_COND = 50.0  # W/m/K, acier / steel
SOURCE = 1.0e6  # W/m³, chauffage uniforme / uniform heating

Mailler. Une ligne de 20 segments, balayée sur la hauteur, donne une grille de 20 × 20 quadrangles. Le bord est repris en nœuds, pour y imposer la température :

    # FR — Une ligne de N segments, balayée sur la hauteur : N × N QUA4.
    # EN — A line of N segments, swept over the height: N × N QUA4.
    coords = pc.Coords(2)
    a = coords.add_node([0.0, 0.0])
    b = coords.add_node([COTE, 0.0])
    plaque = pc.mesh.extrude(pc.mesh.line(a, b, N), [0.0, COTE], N)

    # FR — Les nœuds du bord, qui seront tenus à 0 °C.
    # EN — The boundary nodes, which will be held at 0 °C.
    bord = pc.mesh.to_poi1(pc.mesh.border(plaque))

Déclarer la physique. La conduction sur la plaque, le blocage du bord et la source de chaleur sont trois termes d’un même modèle, réunis par | ; le modèle réclame ensuite ses coefficients, la conductivité « k » et la densité de source « phi_q » :

    # FR — La conduction sur la plaque, le bord bloqué, la source partout.
    # EN — Conduction over the plate, the boundary held, the source everywhere.
    fes = pc.FiniteElementSpace(plaque)
    modele = pc.model.heat_conduction(fes)
    modele = modele | pc.model.dirichlet(bord, modele, "T")
    modele = modele | pc.model.flux(fes, modele, "q")

    # FR — Les coefficients que le modèle réclame : « k » et la densité « phi_q ».
    # EN — The coefficients the model asks for: "k" and the density "phi_q".
    materiaux = pc.element_field.material_field(
        modele, [("k", K_COND), ("phi_q", SOURCE)]
    )

Résoudre. Le second membre, la matrice, puis la solution :

    # FR — Second membre, matrice, résolution. Aucune température imposée à
    #      écrire : une valeur absente vaut 0.
    # EN — Right-hand side, matrix, solution. No imposed temperature to write:
    #      a missing value is 0.
    second_membre = pc.node_field.external_forces(modele, materiaux)
    K = pc.matrix.stiffness(modele, materiaux)
    temperature = pc.solver.solve(K, second_membre)
    print(f"T max = {temperature.max('T'):.2f} °C")

Tracer.

    # FR — Une fenêtre s'ouvre ; le script se termine quand on la ferme.
    # EN — A window opens; the script ends when it is closed.
    plaque.plot(
        field=temperature,
        component="T",
        view=(0, 90, 1.0),  # FR — vue de dessus / EN — top view
        title="Température (°C)",
        save=os.path.join(OUT, "premier-calcul.svg") if OUT else None,
    )

Le centre monte à 14,76 °C. La solution exacte de ce problème est connue : \( T_{\max} \approx 0{,}0737\, q L^2 / k = 14{,}73 \) °C. Le maillage de 20 × 20 éléments s’en approche à 0,2 % près.

Le lancer

Enregistrez premier_calcul.py (bouton Download raw file de la page) dans un dossier, ouvrez un terminal dans ce dossier, et lancez :

python premier_calcul.py

Le terminal affiche T max = 14.76 °C et une fenêtre s’ouvre : la souris fait tourner la vue, la molette zoome. Le script se termine quand on ferme la fenêtre.

Les scripts du tutoriel

Chaque page qui suit est construite sur un script complet, rangé dans le dossier tutoriel/ du dépôt. Le code affiché dans les pages est extrait directement de ces scripts, qui sont exécutés à chaque vérification du projet : ce que vous lisez est ce qui tourne. Chaque page se termine par son script complet.

Pour suivre les pages qui partent de Maillage, enregistrez leurs scripts dans le même dossier — chacun reprend le maillage et les données des précédents — puis lancez-les dans cet ordre, comme le premier calcul. Les scripts de dilatation et de plasticité relisent le fichier qu’écrit thermique_non_lineaire.py : il doit avoir tourné avant eux.

python maillage.py
python thermique.py
python thermique_transitoire.py
python thermique_non_lineaire.py
python mecanique_elastique.py
python mecanique_dilatation.py
python mecanique_heterogene.py
python plasticite.py
python contact.py

Chaque figure s’ouvre dans sa fenêtre ; fermez-la pour passer à la suivante.

Le parcours

  1. Premiers pas — cette page.
  2. Ce que fait pyrucast — les étapes d’un calcul, les physiques disponibles, où trouver la documentation.
  3. Python & conventions pyrucast — le Python des scripts, les raccourcis de pyrucast, où chercher une fonction.
  4. Maillage — mailler la chape, en non structuré (triangulation) puis en structuré quadratique (balayages).
  5. Thermique linéaire — l’équation de la chaleur, puis ses deux régimes : stationnaire (conduction, flux imposé, convection et source, sur des régions repérées par leur géométrie) et transitoire (les sollicitations qui s’établissent dans le temps, le flux de chaleur).
  6. Thermique non linéaire — le rayonnement, en kelvins ; le champ parcouru dans le temps, la sauvegarde du calcul.
  7. Mécanique linéaire — l’équilibre et la loi de Hooke, puis la chape encastrée sous une masse : élasticité (trois façons de poser la masse, déformée, contraintes, von Mises le long d’une ligne), dilatation (la température relue du calcul thermique) et matériau hétérogène (un coefficient de dilatation qui varie).
  8. Mécanique non linéaire — le calcul pas à pas : plasticité (une limite d’élasticité constante, puis selon la température) et contact (un appui posé dans l’alésage).
  9. Compléments — pour aller plus loin.

Ce que fait pyrucast

En bref

pyrucast est une bibliothèque de calcul par éléments finis. Son cœur est écrit en Rust, un langage compilé, rapide, qui répartit le travail sur tous les cœurs du processeur ; on le pilote depuis Python. Un calcul est donc un script, du premier point posé au dernier résultat tracé.

pyrucast n’a pas d’interface graphique : les figures sont produites par le script lui-même, dans une fenêtre ou dans des fichiers d’image, et les résultats s’exportent vers les logiciels de visualisation (ParaView, par le format VTK). C’est un logiciel libre.

Les étapes d’un calcul

Tout calcul suit les mêmes étapes, et chacune a son objet ou sa famille de fonctions :

étapece qu’on faitdans pyrucastvu dans
géométrie, maillageposer des points, mailler lignes, surfaces et volumesCoords, pyrucast.meshPremiers pas, Maillage
espace éléments finischoisir l’interpolation sur le maillageFiniteElementSpacePremiers pas, Régime stationnaire
modèledéclarer la physique, ses blocages, ses chargementspyrucast.modelRégime stationnaire, Élasticité, Contact
matériauxdonner les coefficients que le modèle réclamepyrucast.element_field.material_fieldRégime stationnaire
second membreintégrer les chargementspyrucast.node_field.external_forcesRégime stationnaire
assemblageconstruire la matrice du systèmepyrucast.matrix.stiffnessRégime stationnaire
résolutionrésoudre le systèmepyrucast.solver.solveRégime stationnaire, Élasticité
exploitationtracer, extraire, exporter, sauvegarderplot, pyrucast.evolution.along, pyrucast.export.export_vtk, pyrucast.saveMaillage, Régime stationnaire, Thermique non linéaire, Élasticité

Quand le problème n’est pas linéaire ou évolue dans le temps, le calcul pas à pas (pyrucast.stepping.step_by_step) répète les étapes de l’assemblage à la résolution à chaque instant, jusqu’à l’équilibre : voir Régime transitoire et Mécanique non linéaire.

Les domaines couverts

Chaque ligne renvoie à la page qui la détaille.

Thermique. Conduction isotrope, orthotrope ou anisotrope, en régime stationnaire ou transitoire ; convection et flux imposé, sources volumiques ; rayonnement vers l’environnement. Dans ce tutoriel : régime stationnaire, régime transitoire, rayonnement.

Diffusion. La loi de Fick, pour une ou plusieurs espèces.

Mécanique des milieux continus, en contraintes planes, déformations planes, axisymétrie ou 3D :

Éléments de structure. Barre, poutres d’Euler-Bernoulli et de Timoshenko (portiques plans, cadres spatiaux), coques.

Conditions et liaisons. Blocages, relations entre nœuds, barres noyées dans un volume, contact sans frottement ; en égalité ou en inégalité (voir Contraintes).

Calcul pas à pas. À chaque instant, la thermique puis la mécanique à la température atteinte ; les lois non linéaires, le contact, la dynamique, le pilotage du chargement — voir Calcul pas à pas.

Éléments finis. Quinze types, linéaires et quadratiques, du segment à l’hexaèdre à 27 nœuds — voir Éléments finis supportés.

Maillage. Des mailleurs intégrés — lignes et arcs, balayages, triangulation de surfaces et de volumes, pavage en quadrangles, couche limite d’hexaèdres — et la lecture des maillages gmsh et MED : voir Maillage et Mailler une géométrie.

Résultats. Tracés dans une fenêtre ou en PNG/SVG, export VTK, sauvegarde d’une session dans un fichier .pyr.

Où trouver la documentation

Compiler pyrucast soi-même

La version précompilée suffit pour tout ce tutoriel. Compiler pyrucast depuis ses sources ne sert qu’à l’installer avec la visualisation sur une plateforme qui n’a pas de version précompilée, ou à travailler sur pyrucast lui-même. Il faut alors Rust et l’outil maturin : la marche à suivre est dans Installation et démarrage rapide.

Suite : Python & conventions pyrucast.

Python & conventions pyrucast

Un script pyrucast est un programme Python ordinaire : il n’y a pas de langage à part à apprendre. Cette page rassemble ce qu’il faut savoir pour lire et écrire les scripts du tutoriel, en deux parties :

  1. le Python des scripts — chaque notion de Python, dans l’ordre où on la rencontre, suivie aussitôt de ce que pyrucast en fait : ses raccourcis ;
  2. les conventions de pyrucast — où trouver une fonction, quand elle existe aussi en méthode, comment enchaîner les appels.

Inutile de tout retenir d’une traite : revenez ici quand une ligne d’un script vous arrête. Tous les extraits de cette page sont exécutés à chaque vérification du projet.

Partie 1 — Le Python des scripts, et les raccourcis de pyrucast

Un script, et comment le lancer

Un script est un fichier texte d’extension .py. Python le lit de haut en bas, une instruction par ligne. Tout ce qui suit un # est un commentaire : Python l’ignore, il est là pour le lecteur. Le texte entre triples guillemets """…""" en tête d’un script le présente.

On le lance depuis un terminal, dans le dossier du script :

python mon_script.py

On peut aussi taper les instructions une à une : la commande python seule ouvre une session interactive (invite >>>), où chaque ligne s’exécute dès qu’on appuie sur Entrée. Un notebook Jupyter fonctionne de la même façon, cellule par cellule.

Nommer des valeurs

COTE = 0.10  # un nombre décimal s'écrit avec un point, jamais une virgule
SOURCE = 1.0e6  # 1,0 × 10⁶ : l'exposant s'écrit après « e »
FLUX = -40_000.0  # les « _ » ne servent qu'à lire les grands nombres
DEMI_COTE = COTE / 2.0  # un calcul, rangé sous un nouveau nom

element = "QUA4"  # du texte, entre guillemets

Le signe = range une valeur sous un nom : COTE vaut désormais 0.10, et on écrit COTE partout où l’on veut cette valeur. Un nombre décimal prend un point ; 1.0e6 se lit « 1,0 × 10⁶ » ; les _ d’un grand nombre ne servent qu’à le lire. Les noms en majuscules sont une convention, pas une règle : ils signalent les données du calcul, posées une fois en tête du script.

Le texte s’écrit entre guillemets. pyrucast en reçoit beaucoup : un type d’élément ("QUA4"), un nom de composante ("T"), un nom de coefficient ("k").

Regrouper des valeurs : listes, tuples, dictionnaires

point = [0.0, 0.10]  # une liste : les coordonnées x, y d'un point
rouge = (255, 0, 0)  # un tuple : une couleur, rouge, vert, bleu
coefficients = [("k", 50.0), ("phi_q", 1.0e6)]  # une liste de paires (nom, valeur)
LONGUEUR, HAUTEUR = 0.30, 0.10  # deux noms d'un coup

print(point[0])  # 0.0 — le premier élément porte le numéro 0
print(coefficients[1])  # ('phi_q', 1000000.0)
  • Une liste, entre crochets […], aligne des valeurs : les coordonnées d’un point, les composantes d’un vecteur, une suite d’instants.
  • Un tuple, entre parenthèses (…), fait de même pour un groupe qui ne change pas : une couleur (rouge, vert, bleu), un point de vue.
  • Une liste de paires (nom, valeur) est la forme sous laquelle pyrucast reçoit les coefficients d’un matériau.
  • Les éléments se numérotent à partir de 0 : point[0] est le premier.
donnees = {"times": [0.0, 0.5, 1.0]}  # des valeurs rangées par nom
donnees["save_times"] = [1.0]  # une entrée de plus
print(donnees["times"])  # [0.0, 0.5, 1.0]

Un dictionnaire, entre accolades {…}, range des valeurs par nom. C’est sous cette forme que le calcul pas à pas reçoit ses données.

Importer pyrucast

import os

import pyrucast as pc  # le paquet, sous le nom court « pc »

coords = pc.Coords(2)  # pc.Coords : la classe Coords du paquet
a = coords.add_node([0.0, 0.0])
b = coords.add_node([0.10, 0.0])
ligne = pc.mesh.line(a, b, 20)  # pc.mesh.line : la fonction line du module mesh

import pyrucast as pc rend tout pyrucast disponible sous le nom court pc. Le point se lit « qui appartient à » : pc.Coords est la classe Coords du paquet, pc.mesh.line la fonction line de son module mesh.

import os charge un module livré avec Python : les scripts du tutoriel s’en servent pour lire une variable d’environnement et composer des chemins de fichiers.

Un script peut aussi reprendre des noms d’un autre script du même dossier : tutoriel/thermique.py commence par from maillage import HEIGHT, HOLE_RADIUS, LENGTH, OUT, THICKNESS, show, structured_mesh, et calcule ainsi sur le maillage construit par tutoriel/maillage.py, sans le recopier. C’est pourquoi les deux fichiers doivent rester côte à côte.

Appeler une fonction

plaque = pc.mesh.extrude(ligne, [0.0, 0.10], 20)  # trois arguments, dans l'ordre
cotes = pc.mesh.border(plaque, angle_deg=45)  # un argument donné par son nom
bord = pc.mesh.border(plaque)  # sans lui : sa valeur par défaut

Une fonction s’appelle avec ses arguments entre parenthèses :

  • par position, dans l’ordre que la fonction attend — pour extrude, le maillage à balayer, le vecteur, le nombre de couches ;
  • par nom, angle_deg=45, quand l’ordre ne suffit pas à se faire comprendre ou que l’argument est facultatif ;
  • un argument facultatif omis prend sa valeur par défaut : sans angle_deg, border rend tout le bord d’un seul tenant, au lieu de le couper en quatre côtés.

Trois valeurs reviennent souvent : True (vrai), False (faux), et None, qui veut dire « rien » — par exemple « pas de fichier, ouvre une fenêtre ».

Pour savoir ce qu’attend une fonction, on demande son aide dans une session interactive :

>>> help(pc.mesh.line)
line(a, b, n_elems, element_type=None)
    Build a line of `n_elems` elements from node `a` to node `b`.

    `element_type` is `"SEG2"` (default) or `"SEG3"`.

Les éditeurs de code (VS Code, PyCharm…) affichent la même aide, et proposent les noms de fonctions et d’arguments au fil de la frappe.

Les objets : méthodes et propriétés

print(plaque.cell_count)  # 400 — une propriété : sans parenthèses
print(plaque.element_types())  # ['QUA4'] — une méthode : avec parenthèses
print(coords.node_count)  # 441
plaque[0].face_color = (255, 0, 0)  # une propriété qui se modifie

Un objet — un magasin de nœuds, un maillage, un champ — porte avec lui deux sortes de choses, qu’on atteint par un point :

  • des méthodes, qui agissent : elles s’appellent avec des parenthèses, plaque.element_types() ;
  • des propriétés, qui sont des valeurs : sans parenthèses, plaque.cell_count. Les nombres que l’on compte (mailles, nœuds, composantes) sont des propriétés dans pyrucast.

Certaines propriétés se modifient : face_color fixe la couleur de tracé d’une zone de maillage. Les scripts du tutoriel s’en servent pour colorier les régions chargées.

Les crochets : zones, composantes, valeurs

La plupart des objets pyrucast sont découpés en zones : un maillage a une zone par type d’élément ou par morceau, un champ une zone par support, un modèle une zone par terme. Les crochets, qui en Python désignent un élément d’une liste, désignent ici une zone :

print(len(cotes))  # 4 — les quatre côtés de la plaque, une zone chacun
bas = cotes[0]  # la première zone
dernier = cotes[-1]  # la dernière
sauf_bas = cotes[1:]  # toutes sauf la première : encore un maillage
milieu = cotes[1:-1]  # ni la première ni la dernière

seulement_T = temperature["T"]  # un nom entre crochets : une composante

champ = pc.NodeField(ligne, ["T"])  # un champ nul aux nœuds de la ligne
champ[0][a, "T"] = 20.0  # une valeur : la zone, puis (nœud, composante)
print(champ[0][a, "T"])  # 20.0
  • x[0] est la première zone, x[-1] la dernière ; len(x) en donne le nombre.
  • x[1:] (« de la deuxième à la fin ») ou x[1:-1] (« ni la première ni la dernière ») rendent un objet de même nature, réduit à ces zones.
  • Une zone n’est pas une copie : la modifier, c’est modifier l’objet entier.
  • Sur un champ, un nom entre crochets garde une seule composante : temperature["T"] laisse de côté "lambda", les multiplicateurs de Lagrange que la résolution ajoute. Une liste de noms en garde plusieurs.
  • Sur une zone de champ, [nœud, composante] lit ou écrit une valeur.

Le détail est dans Champ.

Les opérateurs : réunir et calculer

deux_points = a | b  # deux nœuds : un maillage POI1 de deux points
bas_et_droite = cotes[0] | cotes[1]  # deux zones : un maillage à deux zones

kelvin = temperature["T"] + 273.15  # un nombre ajouté à toutes les valeurs
double = 2.0 * temperature["T"]  # toutes les valeurs multipliées
chaud = temperature["T"] >= 10.0  # 1 là où T ≥ 10 °C, 0 ailleurs

Sur des nombres, + - * / ** (la puissance) font ce qu’on attend. pyrucast leur donne un sens sur ses objets :

  • | réunit : deux nœuds en un maillage POI1, deux maillages en un maillage à plusieurs zones, deux modèles en un seul modèle, deux champs en un seul champ. Il juxtapose, il ne somme jamais (voir plus bas, Réunir sans sommer).
  • + - * / ** calculent sur les valeurs d’un champ, avec un nombre ou avec un autre champ posé sur les mêmes nœuds : temperature + 273.15 passe en kelvins, u + du met à jour un déplacement. -champ change le signe. Les règles, entre deux champs, sont dans Champ.
  • >=, >, <=, < comparent un champ à un nombre et rendent un masque : un champ qui vaut 1 là où la comparaison est vraie, 0 ailleurs.
  • Les matrices s’additionnent et se multiplient ou se divisent par un nombre, et K * u multiplie une matrice par un champ : c’est ainsi que s’écrit le pas en temps C / dt + K de la conduction transitoire.

Répéter : for, et l’indentation

for cote in cotes:
    print(cote.element_type, cote.cell_count)  # SEG2 20, quatre fois

PALETTE = [(255, 0, 0), (0, 170, 0), (0, 0, 255)]
for i, cote in enumerate(cotes):
    cote.face_color = PALETTE[i % len(PALETTE)]  # % : le reste de la division

for cote in cotes: répète le bloc qui suit pour chaque zone de cotes, en la rangeant à chaque tour sous le nom cote. Le bloc répété est celui qui est décalé vers la droite (quatre espaces) : en Python, l’indentation fait partie du langage. Un décalage oublié est une erreur, un décalage en trop change le sens du programme.

enumerate donne en plus le numéro de chaque tour, 0, 1, 2… ; % est le reste de la division, qui fait ici tourner les couleurs de la palette — c’est exactement ce que fait tutoriel/maillage.py pour colorier les faces d’une peau. Pour répéter un bloc n fois, on écrit for i in range(n):, où i vaut 0, 1, …, n - 1.

Les scripts du calcul pas à pas remplissent des listes dans des boucles :

instants = []  # une liste vide…
for i in range(3):
    instants.append(i * 150.0)  # …qui grandit d'un élément à chaque tour
print(instants)  # [0.0, 150.0, 300.0]

couples = [(0.0, 20.0), (150.0, 35.0)]
for t, valeur in couples:  # chaque couple, défait en deux noms
    print(t, valeur)

for avant, apres in zip([20.0, 35.0], [21.0, 37.0]):  # deux listes à la fois
    print(apres - avant)  # 1.0, puis 2.0

print(max([2, 1, 2]), sum([2, 1, 2]))  # 2 5

liste.append(x) ajoute x au bout de la liste : c’est ainsi que tutoriel/thermique_transitoire.py construit la liste de ses instants, et une liste de couples (temps, température) par point suivi. Quand chaque élément est un couple, for t, valeur in couples: le défait en deux noms. zip(a, b) parcourt deux listes ensemble, un élément de chacune à chaque tour — le script du rayonnement s’en sert pour lire, instant par instant, ses deux calculs côte à côte. max(liste) et sum(liste) donnent le plus grand élément et la somme.

Choisir : if

OUT = os.environ.get("PYRUCAST_TUTORIEL_IMG_DIR")  # None si la variable n'existe pas
if OUT:
    print("les figures seront écrites dans", OUT)
else:
    print("les figures s'ouvriront dans une fenêtre")

if n’exécute son bloc (décalé, lui aussi) que si la condition est vraie ; else donne l’autre branche. None, 0 et un texte vide comptent pour « faux ». Les scripts du tutoriel l’emploient pour choisir entre une fenêtre et un fichier : os.environ.get lit une variable d’environnement — un réglage posé dans le terminal avant de lancer le script, ici le dossier où écrire les figures — et rend None si elle n’existe pas.

Définir une fonction : def

Quand un même geste revient souvent, on le range dans une fonction. Voici celle que tutoriel/maillage.py définit pour tracer ses figures :

# FR — Répertoire des figures : défini → export SVG, absent → fenêtre interactive.
# EN — Figure directory: set → SVG export, unset → interactive window.
OUT = os.environ.get("PYRUCAST_TUTORIEL_IMG_DIR")

# FR — Vue commune à toutes les figures : azimut, élévation, échelle.
# EN — View shared by every figure: azimuth, elevation, scale.
VUE = (-45, 25, 1.0)

# FR — Une couleur par face plane de la peau (recyclée s'il y en a plus).
# EN — One colour per flat skin face (recycled if there are more of them).
PALETTE = [(255, 0, 255), (0, 255, 0), (0, 0, 255), (255, 0, 0), (255, 255, 0)]


def show(mesh: pc.Mesh, title: str, file: str, wireframe: bool = False) -> None:
    """FR — Trace `mesh` : fenêtre interactive, ou SVG si `OUT` est défini.

    EN — Plot `mesh`: interactive window, or SVG when `OUT` is set.
    """
    mesh.plot(
        view=VUE,
        title=title,
        wireframe=wireframe,
        save=os.path.join(OUT, file) if OUT else None,
    )


  • def show(…): définit la fonction show ; son corps est le bloc décalé.
  • mesh, title, file, wireframe sont ses paramètres ; wireframe: bool = False lui donne une valeur par défaut.
  • : pc.Mesh, : str, -> None disent quels types sont attendus et rendus. Ce sont des indications pour le lecteur et l’éditeur : Python ne les vérifie pas.
  • x if condition else y est un choix en une ligne : le chemin du fichier si OUT est défini, None (donc une fenêtre) sinon.
  • Une fonction rend un résultat par return. structured_mesh en rend deux, return grid, volume_structure, que tutoriel/thermique.py reçoit par _, volume = structured_mesh(plot=False) — le nom _ signale une valeur dont on ne se sert pas.

Chaque script range enfin son travail dans une fonction main(), et se termine par :

if __name__ == "__main__":
    main()

Cette condition n’est vraie que si l’on lance le fichier. Quand un autre script l’importe, main() ne s’exécute pas : tutoriel/thermique.py reprend le maillage de tutoriel/maillage.py sans retracer toutes ses figures.

Un bloc dans un contexte : with

with exécute un bloc décalé dans un contexte, et le défait à la sortie, même si le bloc s’arrête sur une erreur. pyrucast s’en sert pour calculer dans une autre configuration des coordonnées — la pièce déformée plutôt que la référence :

import pyrucast

c = pyrucast.Coords(dim=2)
n = c.add_node([1.0, 0.0])
deformee = c.add_config("deformed")

# Le bloc voit `deformee` ; la configuration d'avant revient à la sortie,
# même après une exception.
with c.in_config(deformee):
    n.set_position([1.5, 0.0])
    x = n.position()

print(x)  # [1.5, 0.0]
print(n.position())  # [1.0, 0.0] — la référence, de nouveau active

C’est un usage avancé, dont le tutoriel ne se sert pas : voir Coordonnées.

Afficher : print

print(…) écrit dans le terminal. Un texte précédé de f, une f-string, y insère des valeurs entre accolades : print(f"T max = {temperature.max('T'):.2f} °C") affiche la température maximale avec deux chiffres après la virgule.

Tout objet pyrucast s’affiche à trois niveaux de détail :

print(ligne)  # Mesh: 1 submesh(es) [#…], 20 cell(s) total — le résumé
print(repr(ligne))  # la structure : chaque zone, son type, son nombre de mailles
ligne.dump()  # tout le contenu, maille par maille

print(x) donne le résumé d’une ligne, repr(x) la structure, x.dump() le contenu complet — voir Aspect informatique.

Lire un message d’erreur

Quand une instruction échoue, Python s’arrête et affiche un message de ce genre :

Traceback (most recent call last):
  File "mon_script.py", line 14, in <module>
    bas = cotes.unit()
RuntimeError: expected a unitary Mesh (exactly one submesh(es)), found 4

Il se lit par le bas. La dernière ligne dit ce qui ne va pas — le type de l’erreur, puis le message, en anglais. Les lignes au-dessus disent où : le fichier, le numéro de ligne, l’instruction. Ici, unit() rend la zone unique d’un maillage, et cotes en a quatre : il fallait choisir une zone, cotes[0]. Les messages de pyrucast disent ce qui était attendu et ce qui a été trouvé.

Partie 2 — Les conventions de pyrucast

Trois sortes d’objets

  • Coords, le magasin de nœuds. Un script en crée un, en général, et tous les mailleurs y déposent leurs nœuds : c’est ce qui fait que deux maillages construits côte à côte partagent leurs nœuds communs.
  • Node, un nœud de ce magasin.
  • Les conteneurs, découpés en zones, chacun avec sa page dans la partie Objets :
objetcontientune zone
Meshun maillageSubMesh
FiniteElementSpacel’interpolation choisie sur un maillageSubFiniteElementSpace
NodeFielddes valeurs aux nœudsSubNodeField
ElementFielddes valeurs aux points de GaussSubElementField
Modelune physique, ses conditions, ses chargementsSubModel
Matrixune matrice creuseSubMatrix
Evolutiondes valeurs tabulées (une courbe, un champ qui évolue)SubEvolution

Un objet se déclare à partir d’une description, et naît souvent vide :

vide = pc.Mesh(coords, "TRI3")  # un maillage de triangles, encore sans maille
fes = pc.FiniteElementSpace(plaque)  # l'espace éléments finis de la plaque
nul = pc.NodeField(ligne, ["T"])  # un champ nul aux nœuds de la ligne

Tout le reste se calcule, par des fonctions.

Les fonctions, rangées par ce qu’elles produisent

Une fonction vit dans le module qui porte le nom de ce qu’elle rend. Pour trouver une fonction, demandez-vous ce que vous voulez obtenir :

modulerendexemples du tutoriel
pyrucast.meshun maillageline, arc, circle, extrude, sweep, triangulate_surface, skin, border, select, elements_on, points_on_cylinder, to_poi1
pyrucast.node_fieldun champ aux nœudspositions, restrict, external_forces
pyrucast.element_fieldun champ aux points de Gaussmaterial_field, gradient, integrate_behavior
pyrucast.modelun modèleheat_conduction, boundary_transfer, flux, dirichlet, elasticity
pyrucast.matrixune matricestiffness, mass

pyrucast.mesh.select rend un maillage — les nœuds retenus — bien qu’il parte d’un champ : c’est le résultat qui décide du module.

Les fonctions qui ne rendent aucun de ces objets sont rangées par activité :

moduleactivitéexemple
pyrucast.solverrésoudre un systèmesolve
pyrucast.measuremesurer un nombre (une intégrale…)integral
pyrucast.exportécrire pour un autre logicielexport_vtk
pyrucast.coordsdéplacer les nœuds du magasindisplace
pyrucast.fieldles fonctions mathématiques, sur tout champsqrt, cos, exp
pyrucast.steppingle calcul pas à passtep_by_step

Restent, au niveau du paquet lui-même, pyrucast.save et pyrucast.load, qui écrivent et relisent une session (voir Sauvegarde).

La même opération en méthode

Beaucoup de fonctions existent aussi en méthode de leur premier argument :

plaque_f = pc.mesh.extrude(ligne, [0.0, 0.10], 20)  # la fonction
plaque_m = ligne.extrude([0.0, 0.10], 20)  # la même, en méthode de `ligne`

Les deux écritures font exactement la même chose, et la méthode a la même aide que la fonction. Une fonction a sa méthode quand trois conditions sont réunies :

  1. son premier argument est l’objet que l’opération transforme ;
  2. elle rend un objet pyrucast ;
  3. elle a un sens pour n’importe quel objet de ce type.
fonctionméthode
pyrucast.mesh.extrude(ligne, v, n)ligne.extrude(v, n)
pyrucast.mesh.border(plaque)plaque.border()
pyrucast.mesh.skin(volume)volume.skin()
pyrucast.mesh.consolidate(m)m.consolidate()
pyrucast.mesh.to_poi1(m)m.to_poi1()
pyrucast.node_field.positions(m, ["X"])m.positions(["X"])
pyrucast.mesh.select(champ, ge=…)champ.select(ge=…)
pyrucast.node_field.restrict(champ, m)champ.restrict(m)
pyrucast.element_field.material_field(modele, …)modele.material_field(…)
pyrucast.node_field.external_forces(modele, mat)modele.external_forces(mat)
pyrucast.matrix.stiffness(modele, mat)modele.stiffness_matrix(mat)
pyrucast.solver.solve(K, f)K.solve(f)

Quand le module précisait la nature du résultat, la méthode la reprend dans son nom : pyrucast.matrix.stiffness devient stiffness_matrix.

Restent seulement des fonctions :

  • les physiques de pyrucast.model : leur premier argument est l’espace sur lequel on déclare la physique, pas un objet qu’elles transforment ;
  • pyrucast.mesh.line(a, b, n), dont le premier argument est un nœud ;
  • pyrucast.element_field.deformation, qui n’a de sens que pour un champ portant des déplacements u_x, u_y… — pas pour n’importe quel champ.

Composer les appels : imbriquer ou enchaîner

Le résultat d’un appel peut servir directement d’argument au suivant. Avec les fonctions, les appels s’imbriquent, et se lisent de l’intérieur vers l’extérieur :

plaque = pc.mesh.extrude(pc.mesh.line(a, b, 20), [0.0, 0.10], 20)
bord = pc.mesh.to_poi1(pc.mesh.border(plaque))

Avec les méthodes, ils s’enchaînent, et se lisent de gauche à droite, dans l’ordre des opérations — « une ligne, balayée ; son bord, en nœuds » :

plaque = pc.mesh.line(a, b, 20).extrude([0.0, 0.10], 20)
bord = plaque.border().to_poi1()

La résolution du premier calcul, des deux façons :

temperature = pc.solver.solve(
    pc.matrix.stiffness(modele, materiaux),
    pc.node_field.external_forces(modele, materiaux),
)
second_membre = modele.external_forces(materiaux)
temperature = modele.stiffness_matrix(materiaux).solve(second_membre)

Et deux lignes de tutoriel/thermique.py, écrites en méthodes (la forme fonction est rappelée en commentaire) :

# thermique.py : pc.mesh.consolidate(pc.mesh.skin(volume))
peau = volume.skin().consolidate()

# thermique.py : pc.mesh.select(pc.node_field.positions(peau, ["Z"]), ge=-TOL, le=TOL)
noeuds_bas = peau.positions(["Z"]).select(ge=-TOL, le=TOL)

Les scripts du tutoriel écrivent tout en fonctions, une opération par ligne : c’est la forme que décrit la documentation de chaque opérateur. Les deux formes se mélangent librement ; choisissez celle qui se lit le mieux.

Réunir sans sommer : | et les zones

conduction = pc.model.heat_conduction(fes)
modele = conduction | pc.model.dirichlet(bord, conduction, "T")
modele = modele | pc.model.flux(fes, modele, "q")
print(len(modele))  # 3 — trois termes réunis par |

| ajoute chaque terme au modèle comme une zone de plus : len(modele) en compte trois. unit() rend la zone unique d’un objet qui n’en a qu’une — et s’arrête sur une erreur sinon, comme plus haut. pyrucast.mesh.consolidate fond en une seule les zones d’un maillage qui ont le même type d’élément ; plusieurs mailleurs l’exigent.

Sur les champs, | juxtapose aussi les zones : à un nœud que deux zones partagent, il ne somme pas les valeurs. Pour additionner deux chargements qui se touchent, on les ramène d’abord sur les mêmes nœuds avec pyrucast.node_field.restrict, puis on les somme par + — c’est l’objet du passage Trois chargements qui se touchent de la page Régime stationnaire.

Les noms des composantes

print(conduction.dual_of("T"))  # q — la duale de la température : le flux
blocage = pc.model.dirichlet(bord, conduction, "T")  # on bloque la primale « T »

mecanique = pc.model.elasticity(fes, "plane_stress")
print(mecanique.dual_of("u_x"))  # f_x — la duale du déplacement : la force
appui = pc.model.dirichlet(bord, mecanique, "u_x")  # on bloque la primale « u_x »

Chaque physique déclare ses inconnues par paires : une variable primale, l’inconnue elle-même, et sa duale, la grandeur qui lui répond — T et le flux q en thermique, u_x et la force f_x en mécanique. dirichlet reçoit le nom de la primale, celle que l’on impose, et trouve la duale dans le modèle visé ; dual_of la donne.

Les coefficients des matériaux se désignent eux aussi par leur nom : « k » pour la conduction, « phi_q » pour une densité de flux, « h_T » et « a_ext_T » pour la convection. pyrucast.element_field.material_field sait lesquels le modèle réclame. Chaque page de Détails des physiques donne la liste des siens.

Suite : Maillage.

Maillage

Fil rouge du tutoriel : une chape percée — plaque rectangulaire terminée par un demi-disque, trouée en son centre.

Plaque de 30 cm × 10 cm, demi-disque de rayon 5 cm, trou de rayon 3,5 cm centré sur le demi-disque, épaisseur 2 cm. La pièce est plane dans XZ et son épaisseur est portée par Y : la géométrie est en Coords(3) dès le départ, il n’y a donc rien à relever au moment de passer au volume.

Deux familles de mailleurs coexistent :

on donne…le mailleur…topologie
non structuréune taille de maille cibleplace ses propres nœuds à l’intérieurquelconque
structuréun nombre d’élémentsbalaie une ligne sur une autregrille

Le script complet est tutoriel/maillage.py ; tous les extraits ci-dessous en sont issus directement, dans l’ordre du fichier — sauf le petit utilitaire de tracé, défini en tête du script et présenté en fin de page.

Géométrie

L’espace de coordonnées

# FR — Un espace de coordonnées 3D, où tous les mailleurs déposent leurs nœuds.
# EN — One 3-D coordinate space, where every mesher drops its nodes.
coords = pc.Coords(3)

pyrucast.Coords est le magasin commun de tous les nœuds du script. Tous les mailleurs y déposent leurs nœuds, ce qui garantit que deux maillages construits côte à côte partagent bien leurs nœuds communs — c’est ce qui rend possible, plus bas, de raccorder une couronne à une grille sans maillage non conforme.

L’argument 3 est la dimension de l’espace : la pièce est plane, mais on la décrit d’emblée en 3D pour n’avoir rien à relever au moment de l’extruder.

Les cotes

# FR — Paramètres géométriques / EN — Geometrical parameters
LENGTH, HEIGHT = 0.30, 0.10  # m
HOLE_RADIUS = 0.035  # m
THICKNESS = 0.02  # m

Toutes les dimensions sont en mètres et nommées une seule fois : elles servent aux points guides, aux rayons du trou, à l’épaisseur d’extrusion, et les pages de Thermique linéaire les réimportent telles quelles pour placer leurs conditions aux limites.

Points guides

On pose les quelques points qui définissent la pièce — p1/p2/p4/p5 sont les coins du rectangle, p6 le centre du demi-disque et du trou, p3 la pointe.

# FR — Points guides : les coins, le centre du demi-disque et du trou, la pointe.
# EN — Guide points: the corners, the half-disc and hole centre, the tip.
p1 = coords.add_node([0.0, 0.0, 0.0])
p2 = coords.add_node([LENGTH, 0.0, 0.0])
p3 = coords.add_node([LENGTH + HEIGHT / 2.0, 0.0, HEIGHT / 2.0])
p4 = coords.add_node([LENGTH, 0.0, HEIGHT])
p5 = coords.add_node([0.0, 0.0, HEIGHT])
p6 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0])

Ce sont les seuls nœuds saisis à la main de tout le script : tous les autres sortent d’un mailleur.

Contour fermé

Bord par bord

Le contour se maille bord par bord, chacun avec son mailleur dédié : line pour les côtés droits, arc pour le demi-disque.

def border_plate_with_hole() -> pc.Mesh:
    # FR — Le contour se maille bord par bord : `line` droit, `arc` courbe.
    # EN — The contour is meshed edge by edge: `line` straight, `arc` curved.
    l12 = pc.mesh.line(p1, p2, 30)
    c23 = pc.mesh.arc(p2, p6, p3, 8)
    c34 = pc.mesh.arc(p3, p6, p4, 8)
    l45 = pc.mesh.line(p4, p5, 30)
    l51 = pc.mesh.line(p5, p1, 10)
    cex = l12 | c23 | c34 | l45 | l51

Le nombre d’éléments par bord est décidé ici, et seulement ici. triangulate_surface respecte le contour qu’on lui donne : il ne redécoupe jamais un segment du bord. La finesse du contour est donc un choix de l’utilisateur, indépendant de la taille de maille demandée pour l’intérieur.

mesh.consolidate est obligatoire

    # FR — `|` garde un sous-maillage par bord ; `consolidate` les fusionne.
    # EN — `|` keeps one submesh per edge; `consolidate` fuses them.
    cex = pc.mesh.consolidate(cex)

L’union | réunit les cinq bords dans un même maillage, mais chacun y garde son propre sous-maillage SEG2. Or triangulate_surface exige qu’une boucle fermée tienne dans un seul sous-maillage. pyrucast.mesh.consolidate les fusionne sans toucher à la connectivité.

Le trou, et le contour complet

    # FR — Le trou : cercle centré sur p6, de normale −Y, en 32 segments.
    # EN — The hole: a circle centred on p6, with normal −Y, in 32 segments.
    cin = pc.mesh.circle(p6, [0, -1, 0], HOLE_RADIUS, 32)

    # FR — Deux sous-maillages : le contour extérieur, puis le trou.
    # EN — Two submeshes: the outer contour, then the hole.
    border = cex | cin
    show(border, "Contour de la plaque", "maillage-contour.svg")
    return border

Le trou tient en une commande : un cercle centré sur p6, de normale −Y (donc dans le plan de la pièce), en 32 segments. Le contour renvoyé compte ainsi deux sous-maillages : le contour extérieur, puis le trou.

Maillage non structuré : triangulation

triangulate_surface remplit l’intérieur par triangulation de Delaunay contrainte, raffinée à la taille cible (raffinement de Ruppert).

L’orientation des boucles décide de la matière

def unstructured_mesh(
    border: pc.Mesh,
) -> tuple[pc.Mesh, pc.Mesh, pc.Mesh, pc.Mesh]:
    # FR — Les deux boucles tournent dans le même sens : deux domaines pleins.
    # EN — Both loops wind the same way: two filled-in domains.
    plate_two_domains = pc.mesh.triangulate_surface(border, "TRI3", size=0.01)
    show(
        plate_two_domains,
        "Deux boucles CCW : le disque est rempli",
        "maillage-deux-domaines.svg",
    )

Le point à retenir est l’orientation des boucles. Le mailleur la lit pour savoir ce qui est matière et ce qui ne l’est pas :

  • une boucle antihoraire (CCW) est le bord extérieur d’un domaine ;
  • une boucle horaire (CW) est un trou, contenu dans une boucle extérieure ;
  • plusieurs boucles CCW disjointes maillent plusieurs domaines indépendants.

Ici circle produit une boucle qui tourne dans le même sens que le contour extérieur. Telle quelle, elle n’est donc pas lue comme un trou mais comme un second domaine, et le disque est rempli :

invert fait du disque un vrai trou

    # FR — `invert` retourne la boucle du trou : le mailleur y voit un trou.
    # EN — `invert` flips the hole loop: the mesher then sees a genuine hole.
    border = border[:1] | pc.mesh.invert(border[1:])
    plate = pc.mesh.triangulate_surface(border, "TRI3", size=0.01)
    show(plate, "Plaque non structurée (TRI3)", "maillage-non-structure.svg")
    print(f"unstructured   : {plate.element_types()}, {plate.cell_count} mailles")

invert retourne la boucle du trou — le sous-maillage 1 du contour, d’où le border[:1] | invert(border[1:]) — et le mailleur y voit alors un vrai trou :

size n’est qu’une taille cible : le raffinement insère ses propres nœuds à l’intérieur jusqu’à l’approcher, sans jamais toucher au bord.

Du surfacique au volumique

Quatre opérateurs enchaînés font passer de la surface au volume tétraédrique.

extrude — balayage sur l’épaisseur

    # FR — L'extrusion balaie la surface sur l'épaisseur : un TRI3 donne un PENTA6.
    # EN — Extrusion sweeps the surface through the thickness: TRI3 gives PENTA6.
    volume_extruded = pc.mesh.extrude(plate, [0, THICKNESS, 0], 2)
    show(
        volume_extruded,
        "Volume extrudé (toutes arêtes)",
        "maillage-volume-aretes.svg",
        wireframe=True,
    )
    show(
        volume_extruded,
        "Volume extrudé (faces cachées)",
        "maillage-volume-extrude.svg",
    )

L’extrusion balaie la surface le long d’un vecteur, en un nombre de couches donné. Le type d’élément suit : SEG2 → QUA4, TRI3 → PENTA6, QUA4 → HEX8.

En wireframe=True toutes les arêtes sont tracées, y compris celles de l’intérieur : on voit le maillage traverser la pièce. Le même volume, faces cachées, ne montre que la peau :

skin — la peau, découpée en faces

    # FR — `skin` extrait la peau et la découpe en faces, coupées au-delà de 85°.
    # EN — `skin` extracts the boundary and splits it into faces, cut beyond 85°.
    skin = pc.mesh.skin(volume_extruded, angle_deg=85)
    for i, face in enumerate(skin):
        face.face_color = PALETTE[i % len(PALETTE)]
    show(
        skin[1:-1],
        "Enveloppe QUA4 (faces planes)",
        "maillage-enveloppe-qua4.svg",
        wireframe=True,
    )

skin extrait les facettes du bord du volume et les regroupe par face : deux facettes voisines restent dans la même face tant que leurs normales diffèrent de moins de l’angle donné (85° ici). Chaque facette garde son type : les faces haute et basse de ce volume de prismes sont en TRI3, les chants en QUA4. On obtient cinq sous-maillages — dessous, chant du trou, chant extérieur (le demi-disque compris : sa courbure ne dépasse jamais 85° d’une facette à la suivante), face gauche, dessus — donc colorables et sélectionnables indépendamment, ce qui sert directement à poser les conditions aux limites.

(La figure ne montre que les faces intermédiaires, skin[1:-1] — les trois chants en QUA4 —, pour voir à travers la pièce.)

convert + invert — préparer l’enveloppe

    # FR — `convert` coupe chaque QUA4 en deux TRI3, `invert` sort les normales.
    # EN — `convert` splits each QUA4 into two TRI3, `invert` turns normals out.
    skin = pc.mesh.invert(pc.mesh.convert(skin, "TRI3"))
    show(
        skin[1:-1],
        "Enveloppe TRI3",
        "maillage-enveloppe-tri3.svg",
        wireframe=True,
    )

triangulate_volume n’accepte qu’une enveloppe TRI3 fermée dont les normales sortent de la matière. convert coupe chaque QUA4 en deux TRI3 sans ajouter le moindre nœud ; invert retourne l’ensemble dans le bon sens.

triangulate_volume — remplissage TET4

    # FR — Remplissage TET4 ; le mailleur peut redécouper l'enveloppe donnée.
    # EN — TET4 filling; the mesher may re-cut the envelope it was handed.
    volume_tetra = pc.mesh.triangulate_volume(skin, size=0.01, allow_surface_nodes=True)

C’est le compagnon 3D de triangulate_surface : il remplit l’intérieur de l’enveloppe de tétraèdres.

allow_surface_nodes=True autorise le mailleur à redécouper l’enveloppe là où il ne sait pas la respecter telle quelle. La forme est conservée — chaque nœud ajouté est posé sur l’arête ou la facette qu’il divise — mais la peau du résultat ne coïncide plus maille pour maille avec celle qu’on a fournie. Sans cette autorisation, une telle enveloppe serait refusée plutôt que mal maillée.

Les nœuds ajoutés, un sous-maillage de plus

    # FR — Les nœuds ajoutés forment un second sous-maillage POI1, ici en rouge.
    # EN — The added nodes form a second POI1 submesh, shown here in red.
    volume_tetra[0].face_color = (0, 0, 0)
    for marqueur in volume_tetra[1:]:
        marqueur.face_color = (255, 0, 0)
    show(
        volume_tetra,
        "Volume non structuré triangulé (TET4)",
        "maillage-volume-tetra.svg",
        wireframe=True,
    )

Le résultat porte un sous-maillage de plus. Quand des nœuds ont dû être ajoutés, triangulate_volume prévient sur stderr et les nomme : le maillage renvoyé contient un second sous-maillage, de POI1, à côté des TET4. element_types() vaut donc ['TET4', 'POI1'] et non ['TET4']. Tout ce qui parcourt les sous-maillages ou compte des mailles doit prendre le TET4 seul. Sur la figure ci-dessus, les tétraèdres sont en noir et ce sont ces nœuds ajoutés (3 sur cette pièce) que l’on voit en rouge.

Maillage structuré : grille et couronne

Un nombre d’éléments, plus une taille

def structured_mesh(plot: bool = True) -> tuple[pc.Mesh, pc.Mesh]:
    # FR — Plus de taille de maille : un nombre d'éléments par direction.
    # EN — No element size any more: an element count per direction.
    n15 = 10  # FR — éléments sur la hauteur / EN — elements through the height
    n12 = 20  # FR — éléments sur la longueur / EN — elements along the length

En structuré on ne donne plus une taille de maille mais un nombre d’éléments par direction. La pièce est traitée en deux morceaux : une grille rectangulaire à gauche, une couronne autour du trou à droite.

Les éléments sont quadratiques. Chaque arête porte un nœud en son milieu, en plus de ses deux extrémités : SEG3 pour les lignes, QUA8 pour les surfaces, HEX20 pour le volume. Sur une arête courbe, ce nœud milieu est sur la courbe : le trou et le demi-disque sont suivis exactement, et non par des cordes.

La grille, par balayage du bord gauche

    # FR — La grille : le bord gauche balayé par translation. En SEG3, chaque
    #      segment porte un nœud en son milieu, et un SEG3 balayé donne un QUA8.
    # EN — The grid: the left edge swept by translation. In SEG3 every segment
    #      carries a node at its middle, and a swept SEG3 gives a QUA8.
    l15 = pc.mesh.line(p1, p5, n15, "SEG3")
    x13 = LENGTH - (HEIGHT / 2.0)
    sr1 = pc.mesh.extrude(l15, [x13, 0, 0], n12)

    # FR — `plot=False` : les scripts de thermique importent le volume, sans figures.
    # EN — `plot=False`: the thermal scripts import the volume, without figures.
    if plot:
        show(sr1, "Grille structurée (QUA8)", "maillage-grille.svg")

Balayer plutôt que remplir. extrude(ligne, vecteur, n) balaie une ligne par translation ; un SEG3 balayé donne un QUA8, d’où une grille régulière n12 × n15. Le degré se choisit une fois, sur la ligne de départ ("SEG3") : les mailleurs qui suivent le gardent. La grille s’arrête à x13, l’abscisse où commence le demi-disque.

C’est ici qu’apparaît le premier if plot: : les scripts de thermique importent structured_mesh pour calculer sur son volume et l’appellent avec plot=False, ce qui rend le maillage sans retracer aucune des figures de cette page.

Un bord se récupère, il ne se refabrique pas

    # FR — Le bord droit de la grille ne se refabrique pas : il s'extrait, en SEG3.
    # EN — The grid's right edge is not rebuilt: it is extracted, in SEG3.
    border_sr1 = pc.mesh.border(sr1)
    right_nodes = pc.mesh.select(
        pc.node_field.positions(border_sr1, ["X"]), ge=x13 * (n12 - 0.25) / n12
    )
    l1213 = pc.mesh.elements_on(border_sr1, right_nodes, strict=True)

Pour raccorder la couronne à la grille, il faut le bord droit de la grille. Le reconstruire avec line donnerait une ligne jumelle ne partageant aucun nœud avec la grille, donc un maillage non conforme et une pièce en deux morceaux. On l’extrait : border donne le contour de la grille — en SEG3, puisque la grille est en QUA8 —, une sélection sur la coordonnée X (pyrucast.node_field.positions + pyrucast.mesh.select) garde les nœuds de la dernière colonne, et elements_on(..., strict=True) remonte aux segments dont tous les nœuds y sont. Le seuil est à un quart de maille du bord : les nœuds milieux des segments voisins, à une demi-maille, restent dehors. Aucun indice n’est écrit à la main.

Ses deux extrémités, par coordonnée

    # FR — Ses deux extrémités, la plus basse et la plus haute en Z : à un quart
    #      de maille près, car un nœud milieu est à une demi-maille du bout.
    # EN — Its two ends, the lowest and the highest in Z: within a quarter of a
    #      cell, since a mid-node sits half a cell from the end.
    p13 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), le=0.25 / n15 * HEIGHT
    ).node(0, 0, 0)
    p12 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), ge=(n15 - 0.25) / n15 * HEIGHT
    ).node(0, 0, 0)

Même méthode pour les deux points de raccord de la couronne, repérés par leur coordonnée Z (le plus bas et le plus haut), à un quart de maille près : le nœud milieu du premier segment, à une demi-maille du bout, n’est pas pris. La sélection rend un maillage POI1 d’un seul nœud, dont node(0, 0, 0) extrait le point.

La boucle extérieure de la couronne

    # FR — Boucle extérieure de la couronne : 10+10+5+10+5 = 40 segments SEG3.
    #      Sur les arcs, le nœud milieu de chaque segment est sur le cercle.
    # EN — The ring's outer loop: 10+10+5+10+5 = 40 SEG3 segments. On the
    #      arcs, each segment's mid-node lies on the circle.
    cext = (
        pc.mesh.arc(p2, p6, p3, n15, "SEG3")
        | pc.mesh.arc(p3, p6, p4, n15, "SEG3")
        | pc.mesh.line(p4, p12, int(n15 / 2), "SEG3")
        | l1213
        | pc.mesh.line(p13, p2, int(n15 / 2), "SEG3")
    )
    cext = pc.mesh.consolidate(cext)

Elle réunit le demi-disque, les deux tronçons de bord restants et le bord droit de la grille — 10 + 10 + 5 + 10 + 5 = 40 segments SEG3 — consolidés en une seule boucle fermée, comme pour le contour non structuré. Sur les arcs, arc(..., "SEG3") pose le nœud milieu de chaque segment à mi-angle, sur le cercle.

La boucle intérieure, projetée sur le trou

    # FR — Boucle intérieure : la boucle extérieure projetée sur le trou, chaque
    #      nœud poussé le long du rayon issu de p6 — un nœud du trou en face de
    #      chaque nœud de la boucle extérieure.
    # EN — Inner loop: the outer loop projected onto the hole, each node
    #      pushed along the ray from p6 — one hole node facing each node of the
    #      outer loop.
    cin = pc.mesh.project_on_sphere(cext, p6.position(), HOLE_RADIUS)

    # FR — Les deux boucles, l'une bleue et l'autre rouge : découpages alignés.
    # EN — Both loops, one blue and one red: their cuttings line up.
    if plot:
        cext.unit().face_color, cin.unit().face_color = PALETTE[2], PALETTE[3]
        show(cext | cin, "Les deux boucles de la couronne", "maillage-boucles.svg")

Les deux boucles doivent se correspondre. sweep relie les nœuds de la première boucle à ceux de la seconde, une paire à la fois : il en faut autant de part et d’autre, et en vis-à-vis. On obtient les deux d’un coup en projetant la boucle extérieure sur le trou : pyrucast.mesh.project_on_sphere pousse chaque nœud le long du rayon issu de p6 jusqu’au rayon du trou — dans le plan de la pièce, la sphère est le cercle du trou. Chaque nœud du trou est en face du nœud extérieur dont il vient, nœuds milieux compris, et la boucle projetée est fermée comme l’originale.

La figure montre les deux boucles seules, l’extérieure en bleu et l’intérieure en rouge : 40 segments de chaque côté, chaque nœud intérieur sur le rayon de son vis-à-vis. Le découpage du trou n’est pas régulier : il suit celui de la boucle extérieure. C’est exactement ce que sweep demande.

sweep, puis l’union

    # FR — `sweep` relie les deux boucles par 3 couches de QUA8 ; deux boucles
    #      fermées donnent un anneau fermé, sans couture doublée.
    # EN — `sweep` links both loops with 3 layers of QUA8; two closed loops
    #      give a closed ring, with no doubled seam.
    sh1 = pc.mesh.sweep(cext, cin, 3)
    if plot:
        show(sh1, "Couronne balayée (QUA8)", "maillage-couronne.svg")

    # FR — Grille et couronne partagent les nœuds du bord droit : `|` suffit.
    #      La couronne tourne dans l'autre sens que la grille : `orient` met
    #      toutes les mailles dans le sens de la première, avant l'extrusion.
    # EN — Grid and ring share the right edge's nodes: `|` is enough. The
    #      ring turns the other way from the grid: `orient` turns every cell
    #      the way the first one does, before the extrusion.
    grid = pc.mesh.orient(sr1 | sh1)
    if plot:
        print(f"structured      : {grid.element_types()}, {grid.cell_count} mailles")
        show(grid, "Plaque structurée (QUA8)", "maillage-structure.svg")

sweep(a, b, n) balaie une ligne sur une autre, en n couches : entre deux boucles fermées, c’est le moyen d’obtenir un maillage structuré propre autour d’un trou. Les 40 paires de segments donnent ici 40 × 3 = 120 QUA8. Entre deux lignes SEG3, sweep construit directement des QUA8 et reprend les nœuds milieux des deux boucles : ceux des arcs, sur le cercle.

Deux boucles fermées donnent un anneau fermé. Là où chaque boucle se referme, le rayon qui les relie est partagé par la première et la dernière maille de chaque couche, sans nœud en double : la couronne n’est pas fendue le long de ce rayon.

Grille et couronne partageant les nœuds du bord droit l1213, l’union | suffit ensuite à en faire un maillage conforme — 200 + 120 = 320 QUA8.

Une surface extrudée garde son sens de rotation. La couronne, balayée de la boucle extérieure vers la boucle intérieure, tourne en sens inverse de la grille. Extrudée telle quelle, elle donnerait des hexaèdres retournés, dont les faces pointeraient vers l’intérieur de la matière. Une conduction n’y verrait rien ; une pression ou un contact, qui lisent la normale des faces, s’y inverseraient. pyrucast.mesh.orient met toutes les mailles dans le sens de la première, ici celui de la grille :

Le volume HEX20

    # FR — Même extrusion qu'en non structuré, mais un QUA8 donne un HEX20.
    # EN — Same extrusion as in the unstructured case, but a QUA8 gives an HEX20.
    volume_structure = pc.mesh.extrude(grid, [0, THICKNESS, 0], 2)
    if plot:
        show(
            volume_structure,
            "Volume structuré (HEX20)",
            "maillage-volume-structure.svg",
        )
    return grid, volume_structure

La même extrusion que pour le non structuré donne le volume — mais un QUA8 balayé donne un HEX20, l’hexaèdre quadratique à 20 nœuds : 640 éléments et 4 000 nœuds, dont les nœuds milieux des arêtes.

C’est ce volume que reprennent les pages de Thermique linéaire. Importer la fonction plutôt que recopier sa géométrie n’est pas un raffinement de style — deux maillages construits à l’identique dans deux scripts porteraient des nœuds distincts, et toute condition posée sur l’un serait sans effet sur l’autre.

Visualiser et exporter

Tout passe par la méthode plot(...) de Mesh/SubMesh :

plaque.plot(save="plaque.svg")  # export without a window

# Fenêtre interactive (souris) — seulement s'il y a un écran, sinon `plot`
# raises: neither DISPLAY nor WAYLAND_DISPLAY is set.
if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"):
    plaque.plot(save=None)

save=None ouvre une fenêtre interactive ; save="....png" ou "....svg" exporte sans fenêtre. La version installée par pip install pyrucast comprend les deux ; une version compilée à la main ne les a qu’avec les features viz-interactive (fenêtre et export) ou viz (export seul) — voir Visualisation pour le détail (caméra, colormaps, coloration par champ).

Le script réunit les deux dans une petite fonction, pour que chaque tracé s’ouvre dans une fenêtre quand on lance le script, et devienne une figure de cette page quand on produit les figures du livre :

# FR — Répertoire des figures : défini → export SVG, absent → fenêtre interactive.
# EN — Figure directory: set → SVG export, unset → interactive window.
OUT = os.environ.get("PYRUCAST_TUTORIEL_IMG_DIR")

# FR — Vue commune à toutes les figures : azimut, élévation, échelle.
# EN — View shared by every figure: azimuth, elevation, scale.
VUE = (-45, 25, 1.0)

# FR — Une couleur par face plane de la peau (recyclée s'il y en a plus).
# EN — One colour per flat skin face (recycled if there are more of them).
PALETTE = [(255, 0, 255), (0, 255, 0), (0, 0, 255), (255, 0, 0), (255, 255, 0)]


def show(mesh: pc.Mesh, title: str, file: str, wireframe: bool = False) -> None:
    """FR — Trace `mesh` : fenêtre interactive, ou SVG si `OUT` est défini.

    EN — Plot `mesh`: interactive window, or SVG when `OUT` is set.
    """
    mesh.plot(
        view=VUE,
        title=title,
        wireframe=wireframe,
        save=os.path.join(OUT, file) if OUT else None,
    )


L’attribut face_color d’un sous-maillage fixe sa couleur de tracé, ce qui sert à distinguer les faces d’une peau ou à faire ressortir un groupe de nœuds.

Les figures SVG de ce tutoriel (img/*.svg) sont pré-générées à partir des scripts tutoriel/*.py et commitées avec le livre — mdbook build ne connaît pas Python, elles ne sont donc pas régénérées automatiquement. Après modification d’un script, régénérer avant de committer :

script/generate-tutoriel-figures.sh

Script complet

Une fois les explications retirées, toute la page tient en un script — du premier point guide au volume structuré :

"""Tutoriel débutant — 1. Maillage. / Beginner tutorial — 1. Meshing.

FR — Construit la pièce qui sert de fil rouge : une **chape percée**, plaque
rectangulaire terminée par un demi-disque et trouée en son centre, maillée de
quatre façons successives — contour, surface, volume, puis la même pièce en
**structuré**.

FR — Cotes : 0.30 × 0.10 m, demi-disque de rayon 0.05 m, trou de rayon
0.035 m, épaisseur 0.02 m. La pièce est plane dans **XZ** et son épaisseur est
portée par **Y** : la géométrie est donc 3D (`Coords(3)`) dès le départ, ce qui
évite d'avoir à la relever au moment de passer au volume.

FR — Deux familles de mailleurs sont comparées : le **non structuré**
(`pyrucast.mesh.triangulate_surface`), où l'on donne un contour fermé et une
**taille de maille** cible, et le **structuré** (`pyrucast.mesh.extrude`,
`pyrucast.mesh.sweep`), où l'on impose un **nombre d'éléments** et où le
maillage a la topologie d'une grille. Le structuré est **quadratique**
(SEG3, QUA8, HEX20) : chaque arête porte un nœud milieu, posé sur la courbe
quand l'arête est courbe. Le détail pas à pas est dans le livre, page
« Maillage ».

EN — Builds the part used as the guiding thread: a **pierced lug**, a
rectangular plate capped by a half-disc and holed at its centre, meshed in four
successive ways — contour, surface, volume, then the same part **structured**.

EN — Dimensions: 0.30 × 0.10 m, half-disc of radius 0.05 m, hole of radius
0.035 m, thickness 0.02 m. The part lies in the **XZ** plane and its thickness
runs along **Y**: the geometry is 3-D (`Coords(3)`) from the start, so nothing
has to be lifted when moving on to the volume.

EN — Two families of meshers are compared: **unstructured**
(`pyrucast.mesh.triangulate_surface`), where you hand in a closed contour and
a target **element size**, and **structured** (`pyrucast.mesh.extrude`,
`pyrucast.mesh.sweep`), where you impose an **element count** and the mesh
has grid topology. The structured mesh is **quadratic** (SEG3, QUA8,
HEX20): every edge carries a mid-node, on the curve when the edge is curved.
The step-by-step walkthrough lives in the book's meshing page.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/maillage.py

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

import os

import pyrucast as pc

# FR — Répertoire des figures : défini → export SVG, absent → fenêtre interactive.
# EN — Figure directory: set → SVG export, unset → interactive window.
OUT = os.environ.get("PYRUCAST_TUTORIEL_IMG_DIR")

# FR — Vue commune à toutes les figures : azimut, élévation, échelle.
# EN — View shared by every figure: azimuth, elevation, scale.
VUE = (-45, 25, 1.0)

# FR — Une couleur par face plane de la peau (recyclée s'il y en a plus).
# EN — One colour per flat skin face (recycled if there are more of them).
PALETTE = [(255, 0, 255), (0, 255, 0), (0, 0, 255), (255, 0, 0), (255, 255, 0)]


def show(mesh: pc.Mesh, title: str, file: str, wireframe: bool = False) -> None:
    """FR — Trace `mesh` : fenêtre interactive, ou SVG si `OUT` est défini.

    EN — Plot `mesh`: interactive window, or SVG when `OUT` is set.
    """
    mesh.plot(
        view=VUE,
        title=title,
        wireframe=wireframe,
        save=os.path.join(OUT, file) if OUT else None,
    )




# ── Géométrie / Geometry ──────────────────────────────────────────────────
# FR — Un espace de coordonnées 3D, où tous les mailleurs déposent leurs nœuds.
# EN — One 3-D coordinate space, where every mesher drops its nodes.
coords = pc.Coords(3)

# FR — Paramètres géométriques / EN — Geometrical parameters
LENGTH, HEIGHT = 0.30, 0.10  # m
HOLE_RADIUS = 0.035  # m
THICKNESS = 0.02  # m

# FR — Points guides : les coins, le centre du demi-disque et du trou, la pointe.
# EN — Guide points: the corners, the half-disc and hole centre, the tip.
p1 = coords.add_node([0.0, 0.0, 0.0])
p2 = coords.add_node([LENGTH, 0.0, 0.0])
p3 = coords.add_node([LENGTH + HEIGHT / 2.0, 0.0, HEIGHT / 2.0])
p4 = coords.add_node([LENGTH, 0.0, HEIGHT])
p5 = coords.add_node([0.0, 0.0, HEIGHT])
p6 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0])


# ── Contour fermé / Closed contour ────────────────────────────────────────
def border_plate_with_hole() -> pc.Mesh:
    # FR — Le contour se maille bord par bord : `line` droit, `arc` courbe.
    # EN — The contour is meshed edge by edge: `line` straight, `arc` curved.
    l12 = pc.mesh.line(p1, p2, 30)
    c23 = pc.mesh.arc(p2, p6, p3, 8)
    c34 = pc.mesh.arc(p3, p6, p4, 8)
    l45 = pc.mesh.line(p4, p5, 30)
    l51 = pc.mesh.line(p5, p1, 10)
    cex = l12 | c23 | c34 | l45 | l51

    # FR — `|` garde un sous-maillage par bord ; `consolidate` les fusionne.
    # EN — `|` keeps one submesh per edge; `consolidate` fuses them.
    cex = pc.mesh.consolidate(cex)

    # FR — Le trou : cercle centré sur p6, de normale −Y, en 32 segments.
    # EN — The hole: a circle centred on p6, with normal −Y, in 32 segments.
    cin = pc.mesh.circle(p6, [0, -1, 0], HOLE_RADIUS, 32)

    # FR — Deux sous-maillages : le contour extérieur, puis le trou.
    # EN — Two submeshes: the outer contour, then the hole.
    border = cex | cin
    show(border, "Contour de la plaque", "maillage-contour.svg")
    return border


# ── Maillage non structuré / Unstructured mesh ────────────────────────────
def unstructured_mesh(
    border: pc.Mesh,
) -> tuple[pc.Mesh, pc.Mesh, pc.Mesh, pc.Mesh]:
    # FR — Les deux boucles tournent dans le même sens : deux domaines pleins.
    # EN — Both loops wind the same way: two filled-in domains.
    plate_two_domains = pc.mesh.triangulate_surface(border, "TRI3", size=0.01)
    show(
        plate_two_domains,
        "Deux boucles CCW : le disque est rempli",
        "maillage-deux-domaines.svg",
    )

    # FR — `invert` retourne la boucle du trou : le mailleur y voit un trou.
    # EN — `invert` flips the hole loop: the mesher then sees a genuine hole.
    border = border[:1] | pc.mesh.invert(border[1:])
    plate = pc.mesh.triangulate_surface(border, "TRI3", size=0.01)
    show(plate, "Plaque non structurée (TRI3)", "maillage-non-structure.svg")
    print(f"unstructured   : {plate.element_types()}, {plate.cell_count} mailles")

    # FR — L'extrusion balaie la surface sur l'épaisseur : un TRI3 donne un PENTA6.
    # EN — Extrusion sweeps the surface through the thickness: TRI3 gives PENTA6.
    volume_extruded = pc.mesh.extrude(plate, [0, THICKNESS, 0], 2)
    show(
        volume_extruded,
        "Volume extrudé (toutes arêtes)",
        "maillage-volume-aretes.svg",
        wireframe=True,
    )
    show(
        volume_extruded,
        "Volume extrudé (faces cachées)",
        "maillage-volume-extrude.svg",
    )

    # FR — `skin` extrait la peau et la découpe en faces, coupées au-delà de 85°.
    # EN — `skin` extracts the boundary and splits it into faces, cut beyond 85°.
    skin = pc.mesh.skin(volume_extruded, angle_deg=85)
    for i, face in enumerate(skin):
        face.face_color = PALETTE[i % len(PALETTE)]
    show(
        skin[1:-1],
        "Enveloppe QUA4 (faces planes)",
        "maillage-enveloppe-qua4.svg",
        wireframe=True,
    )

    # FR — `convert` coupe chaque QUA4 en deux TRI3, `invert` sort les normales.
    # EN — `convert` splits each QUA4 into two TRI3, `invert` turns normals out.
    skin = pc.mesh.invert(pc.mesh.convert(skin, "TRI3"))
    show(
        skin[1:-1],
        "Enveloppe TRI3",
        "maillage-enveloppe-tri3.svg",
        wireframe=True,
    )

    # FR — Remplissage TET4 ; le mailleur peut redécouper l'enveloppe donnée.
    # EN — TET4 filling; the mesher may re-cut the envelope it was handed.
    volume_tetra = pc.mesh.triangulate_volume(skin, size=0.01, allow_surface_nodes=True)

    # FR — Les nœuds ajoutés forment un second sous-maillage POI1, ici en rouge.
    # EN — The added nodes form a second POI1 submesh, shown here in red.
    volume_tetra[0].face_color = (0, 0, 0)
    for marqueur in volume_tetra[1:]:
        marqueur.face_color = (255, 0, 0)
    show(
        volume_tetra,
        "Volume non structuré triangulé (TET4)",
        "maillage-volume-tetra.svg",
        wireframe=True,
    )
    return plate, volume_extruded, skin, volume_tetra


# ── Maillage structuré / Structured mesh ──────────────────────────────────
def structured_mesh(plot: bool = True) -> tuple[pc.Mesh, pc.Mesh]:
    # FR — Plus de taille de maille : un nombre d'éléments par direction.
    # EN — No element size any more: an element count per direction.
    n15 = 10  # FR — éléments sur la hauteur / EN — elements through the height
    n12 = 20  # FR — éléments sur la longueur / EN — elements along the length

    # FR — La grille : le bord gauche balayé par translation. En SEG3, chaque
    #      segment porte un nœud en son milieu, et un SEG3 balayé donne un QUA8.
    # EN — The grid: the left edge swept by translation. In SEG3 every segment
    #      carries a node at its middle, and a swept SEG3 gives a QUA8.
    l15 = pc.mesh.line(p1, p5, n15, "SEG3")
    x13 = LENGTH - (HEIGHT / 2.0)
    sr1 = pc.mesh.extrude(l15, [x13, 0, 0], n12)

    # FR — `plot=False` : les scripts de thermique importent le volume, sans figures.
    # EN — `plot=False`: the thermal scripts import the volume, without figures.
    if plot:
        show(sr1, "Grille structurée (QUA8)", "maillage-grille.svg")

    # FR — Le bord droit de la grille ne se refabrique pas : il s'extrait, en SEG3.
    # EN — The grid's right edge is not rebuilt: it is extracted, in SEG3.
    border_sr1 = pc.mesh.border(sr1)
    right_nodes = pc.mesh.select(
        pc.node_field.positions(border_sr1, ["X"]), ge=x13 * (n12 - 0.25) / n12
    )
    l1213 = pc.mesh.elements_on(border_sr1, right_nodes, strict=True)

    # FR — Ses deux extrémités, la plus basse et la plus haute en Z : à un quart
    #      de maille près, car un nœud milieu est à une demi-maille du bout.
    # EN — Its two ends, the lowest and the highest in Z: within a quarter of a
    #      cell, since a mid-node sits half a cell from the end.
    p13 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), le=0.25 / n15 * HEIGHT
    ).node(0, 0, 0)
    p12 = pc.mesh.select(
        pc.node_field.positions(l1213, ["Z"]), ge=(n15 - 0.25) / n15 * HEIGHT
    ).node(0, 0, 0)

    # FR — Boucle extérieure de la couronne : 10+10+5+10+5 = 40 segments SEG3.
    #      Sur les arcs, le nœud milieu de chaque segment est sur le cercle.
    # EN — The ring's outer loop: 10+10+5+10+5 = 40 SEG3 segments. On the
    #      arcs, each segment's mid-node lies on the circle.
    cext = (
        pc.mesh.arc(p2, p6, p3, n15, "SEG3")
        | pc.mesh.arc(p3, p6, p4, n15, "SEG3")
        | pc.mesh.line(p4, p12, int(n15 / 2), "SEG3")
        | l1213
        | pc.mesh.line(p13, p2, int(n15 / 2), "SEG3")
    )
    cext = pc.mesh.consolidate(cext)

    # FR — Boucle intérieure : la boucle extérieure projetée sur le trou, chaque
    #      nœud poussé le long du rayon issu de p6 — un nœud du trou en face de
    #      chaque nœud de la boucle extérieure.
    # EN — Inner loop: the outer loop projected onto the hole, each node
    #      pushed along the ray from p6 — one hole node facing each node of the
    #      outer loop.
    cin = pc.mesh.project_on_sphere(cext, p6.position(), HOLE_RADIUS)

    # FR — Les deux boucles, l'une bleue et l'autre rouge : découpages alignés.
    # EN — Both loops, one blue and one red: their cuttings line up.
    if plot:
        cext.unit().face_color, cin.unit().face_color = PALETTE[2], PALETTE[3]
        show(cext | cin, "Les deux boucles de la couronne", "maillage-boucles.svg")

    # FR — `sweep` relie les deux boucles par 3 couches de QUA8 ; deux boucles
    #      fermées donnent un anneau fermé, sans couture doublée.
    # EN — `sweep` links both loops with 3 layers of QUA8; two closed loops
    #      give a closed ring, with no doubled seam.
    sh1 = pc.mesh.sweep(cext, cin, 3)
    if plot:
        show(sh1, "Couronne balayée (QUA8)", "maillage-couronne.svg")

    # FR — Grille et couronne partagent les nœuds du bord droit : `|` suffit.
    #      La couronne tourne dans l'autre sens que la grille : `orient` met
    #      toutes les mailles dans le sens de la première, avant l'extrusion.
    # EN — Grid and ring share the right edge's nodes: `|` is enough. The
    #      ring turns the other way from the grid: `orient` turns every cell
    #      the way the first one does, before the extrusion.
    grid = pc.mesh.orient(sr1 | sh1)
    if plot:
        print(f"structured      : {grid.element_types()}, {grid.cell_count} mailles")
        show(grid, "Plaque structurée (QUA8)", "maillage-structure.svg")

    # FR — Même extrusion qu'en non structuré, mais un QUA8 donne un HEX20.
    # EN — Same extrusion as in the unstructured case, but a QUA8 gives an HEX20.
    volume_structure = pc.mesh.extrude(grid, [0, THICKNESS, 0], 2)
    if plot:
        show(
            volume_structure,
            "Volume structuré (HEX20)",
            "maillage-volume-structure.svg",
        )
    return grid, volume_structure


def main() -> None:
    contour = border_plate_with_hole()
    unstructured_mesh(contour)
    structured_mesh()
    if OUT:
        print(f"Figures written in {OUT}/")


if __name__ == "__main__":
    main()

Suite : Thermique linéaire, dont les pages reprennent le volume HEX20 structuré de cette page et y posent des conditions aux limites.

Thermique linéaire

Les deux pages de ce chapitre font chauffer la chape percée de la page Maillage, sous les mêmes sollicitations : l’alésage tenu à 250 °C, un flux qui sort par la face gauche, un film convectif sous la pièce et une tranche chauffée dans la masse. La page Régime stationnaire cherche l’état où la pièce s’est installée ; la page Régime transitoire la suit depuis 0 °C, pendant que les sollicitations s’établissent.

Cette page pose l’équation que les deux résolvent ; chacune la reprend ensuite dans sa forme à elle, jusqu’au système que pyrucast résout vraiment.

L’équation de la chaleur

Le bilan d’énergie sur un volume \( V \) s’écrit, avec \( \phi \) la densité de flux de chaleur et \( q \) une source volumique :

\[ \rho c_p \frac{\partial T}{\partial t} + \operatorname{div}(\phi) = q, \qquad \phi = -\lambda \operatorname{grad}(T) \]

\( T \)la température
\( \phi \)la densité de flux de chaleur (loi de Fourier : la chaleur va du chaud vers le froid)
\( q \)la source de chaleur volumique
\( \lambda \)la conductivité thermique
\( \rho \), \( c_p \)la masse volumique et la capacité thermique massique

Sur le bord, deux sortes de conditions se partagent la surface. Sur \( \partial V_T \) la température est imposée ; sur \( \partial V_\phi \) c’est la chaleur reçue par le bord qui est donnée (\( n \) la normale sortante) :

\[ T = T_{\text{imp}} \ \text{ sur } \partial V_T, \qquad -\phi \cdot n = \phi_{\text{imp}} + \underbrace{h\,(T_f - T)}_{\text{convection}} + \underbrace{\varepsilon \sigma\,(T_\infty^4 - T^4)}_{\text{rayonnement}} \ \text{ sur } \partial V_\phi \]

Un \( \phi_{\text{imp}} \) positif apporte de la chaleur, un négatif en retire. La convection échange avec un fluide à \( T_f \), en proportion de l’écart de température ; le rayonnement échange avec un environnement lointain à \( T_\infty \), en puissance quatrième de la température. Ce chapitre-ci laisse le rayonnement de côté : il a sa propre page, Thermique non linéaire.

La forme discrétisée

Sur les éléments finis, la température s’interpole à partir de ses valeurs aux nœuds, \( T(x) = [N(x)]\{T\} \), et son gradient aussi, \( \operatorname{grad}(T) = [B(x)]\{T\} \). L’équation devient un système d’équations différentielles en temps :

\[ [C]\{\dot{T}\} + [K]\{T\} = \{P\} \]

\[ [C] = \int_V \rho c_p\,[N]^T [N] \, dV \qquad \text{la capacité} \]

\[ [K] = \int_V [B]^T \lambda [B] \, dV + \int_{\partial V_\phi} h\,[N]^T [N] \, dS \qquad \text{la conductivité} \]

\[ \{P\} = \int_V [N]^T q \, dV + \int_{\partial V_\phi} [N]^T (\phi_{\text{imp}} + h\,T_f) \, dS \qquad \text{le chargement} \]

La convection se partage entre deux membres : sa part en \( T \) entre dans \( [K] \), sa part en \( T_f \) dans \( \{P\} \). La température imposée, elle, n’entre dans aucun des trois : elle s’ajoute au système par des multiplicateurs de Lagrange, comme le détaille la page Régime stationnaire.

Linéaire veut dire : rien ne dépend de la température. Les coefficients \( \lambda \), \( \rho \), \( c_p \), \( h \) sont des constantes, si bien que \( [C] \), \( [K] \) et \( \{P\} \) se calculent une fois pour toutes. Le rayonnement casse cette propriété — son terme en \( T^4 \) fait dépendre le chargement de l’inconnue —, d’où sa page à part.

La pièce et ses sollicitations

SollicitationOùValeur
température imposéel’alésage250 °C
flux imposéla face gauche, \( x = 0 \)−40 kW/m² (il sort)
convectionla face du dessous, \( z = 0 \)\( h = 240 \) W/m²/K, \( T_f = -80 \) °C
source volumiquela tranche \( 0{,}33\,L \le x \le 0{,}51\,L \)2 600 kW/m³

Le matériau est un acier : \( \lambda = 50 \) W/m/K, et pour le transitoire \( \rho = 7\,800 \) kg/m³, \( c_p = 420 \) J/kg/K, une pièce à 0 °C au départ. Voici ce que donne le régime établi :

Le parcours

  1. Régime stationnaire — \( [C]\{\dot{T}\} \) disparaît : un seul système, résolu deux fois, la conduction seule puis toutes les sollicitations.
  2. Régime transitoire — les sollicitations s’établissent au fil du temps, et la pièce les suit pas de temps par pas de temps ; le flux de chaleur se calcule ensuite sur le dernier instant.

Puis Thermique non linéaire ajoute le rayonnement.

Suite : Régime stationnaire.

Régime stationnaire

Reprend la chape percée de la page Maillage pour un calcul de conduction thermique stationnaire, mené en deux temps : d’abord la conduction seule (température imposée, flux imposé), puis le même problème enrichi d’un film convectif et d’une source volumique. Chaque étape est tracée avant d’être résolue — les régions chargées d’abord, le champ de température ensuite.

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

L’équation résolue

La page Thermique linéaire a posé l’équation de la chaleur et sa forme discrétisée, \( [C]\{\dot{T}\} + [K]\{T\} = \{P\} \). En régime stationnaire la température ne varie plus : le terme de capacité disparaît, et il reste l’équilibre entre la conduction et les sollicitations,

\[ \operatorname{div}(-\lambda \operatorname{grad} T) = q \ \text{ dans } V, \qquad T = T_{\text{imp}} \ \text{ sur } \partial V_T, \qquad -\phi \cdot n = \phi_{\text{imp}} + h\,(T_f - T) \ \text{ sur } \partial V_\phi \]

— la troisième équation compte la chaleur reçue par le bord (\( n \) la normale sortante) : un \( \phi_{\text{imp}} \) positif en apporte, les −40 kW/m² de la face gauche en retirent. Le terme en \( h \) est la convection (loi de Newton) : un échange avec un fluide à \( T_f \), proportionnel à l’écart de température. Il dépend de l’inconnue, donc il ne se range pas entièrement au second membre — on y revient plus bas.

Discrétisée sur les éléments finis, avec \( T(x) = [N(x)]\{T\} \) et \( \operatorname{grad}(T) = [B(x)]\{T\} \), l’équation devient un système linéaire :

\[ [K]\{T\} = \{P\} \]

\[ [K] = \int_V [B]^T \lambda [B] \, dV + \int_{\partial V_\phi} h\,[N]^T [N] \, dS \]

\[ \{P\} = \int_V [N]^T q \, dV + \int_{\partial V_\phi} [N]^T (\phi_{\text{imp}} + h\,T_f) \, dS \]

La température imposée n’entre pas dans \( [K] \) : elle passe par des multiplicateurs de Lagrange \( \{\mu\} \), un par nœud bloqué. La relation \( [A]\{T\} = \{T_{\text{imp}}\} \) — chaque ligne de \( [A] \) lit la température d’un nœud de l’alésage — s’ajoute au système, et c’est celui-ci que pyrucast résout :

\[ \begin{bmatrix} K & A^T \\ A & 0 \end{bmatrix} \begin{Bmatrix} T \\ \mu \end{Bmatrix} = \begin{Bmatrix} P \\ T_{\text{imp}} \end{Bmatrix} \]

Les \( \{\mu\} \) sont les multiplicateurs, et leur opposé est la réaction : \( -[A]^T\{\mu\} \), les flux qu’il faut injecter aux nœuds bloqués pour les tenir à 250 °C.

Le second membre se vérifie par sa somme. Les fonctions de forme valent 1 à elles toutes en chaque point, si bien que la somme des lignes de \( \{P\} \) est la puissance que les sollicitations apportent à la pièce :

\[ \sum_i P_i = \int_V q \, dV + \int_{\partial V_\phi} (\phi_{\text{imp}} + h\,T_f) \, dS \]

— le contrôle que l’on fait plus bas sur le second membre de l’étape 2.

Chaque terme correspond à un objet du script :

Termepyrucast
\( \int_V [B]^T \lambda [B] \, dV \)pc.model.heat_conduction(fes), assemblé par pc.matrix.stiffness
\( \int_{\partial V_\phi} h [N]^T [N] \, dS \)pc.model.boundary_transfer(fes, conduction, ["T"]), dans la même matrice
\( [A]\{T\} = \{T_{\text{imp}}\} \)pc.model.dirichlet(alesage, modele, "T"), dont les multiplicateurs \( \{\mu\} \) sont la composante « lambda » de la solution
\( \int_{\partial V_\phi} [N]^T \phi_{\text{imp}} \, dS \)pc.model.flux(fes, modele, "q") sur des QUA8, densité phi_q, lu par pc.node_field.external_forces
\( \int_{\partial V_\phi} [N]^T h\,T_f \, dS \)porté par le même pc.model.boundary_transfer, ambiant a_ext_T, lu par pc.node_field.external_forces
\( \int_V [N]^T q \, dV \)pc.model.flux(fes, modele, "q") sur des HEX20, densité phi_q, lu par pc.node_field.external_forces

Les trois dernières lignes disent deux choses. D’abord, un chargement est un terme du modèle : on le réunit au modèle, sa densité entre dans le champ matériau, et pc.node_field.external_forces calcule le second membre de tous les termes à la fois. Ensuite, flux est l’unique opérateur de charge répartie (première et dernière de ces lignes), quelle que soit la dimension du sous-espace éléments finis sur lequel on l’applique — une face QUA8 intègre une densité surfacique, un HEX20 une densité volumique. Flux surfacique, pression et source volumique passent donc tous par le même opérateur.

Les données du calcul

K_COND = 50.0  # W/m/K
FLUX_IMPOSE = -40_000.0  # W/m², face gauche / left face
H_CONV, T_EXT = 240.0, -80.0  # W/m²/K, °C — convection, face z = 0
SOURCE_VOLUMIQUE = 2600e3  # W/m³, zone chauffée / heated zone (≈ 260 W)
T_IMPOSEE = 250.0  # °C, alésage / bore

# FR — La zone chauffée est une tranche de la pièce, entre deux abscisses.
# EN — The heated zone is a slice of the part, between two abscissae.
SOURCE_X_MIN, SOURCE_X_MAX = 0.33 * LENGTH, 0.51 * LENGTH

# FR — Une face plane vaut zéro à l'arrondi près : on sélectionne une bande.
# EN — A flat face is zero up to rounding: a band is selected, not a value.
TOL = 1e-9  # m

Toutes les valeurs physiques sont groupées en tête de fichier, en unités SI : un acier (\( \lambda = 50 \) W/m/K), un flux sortant de −40 kW/m² sur la face gauche, un film convectif assez vif (\( h = 240 \) W/m²/K vers un fluide à −80 °C), une source de 2,6 MW/m³ dans la tranche chauffée (environ 260 W au total) et l’alésage tenu à 250 °C.

TOL mérite un mot : les nœuds d’une face plane valent zéro à l’arrondi machine près, jamais exactement zéro. Une sélection par coordonnée se fait donc toujours sur une bande, [-TOL, TOL], et la tolérance est ici explicite plutôt que cachée dans le mailleur.

On ne remaille pas : on importe

Le script ne redonne aucune cote. Il importe structured_mesh de tutoriel/maillage.py et calcule sur le volume HEX20 structuré de la page précédente — 640 hexaèdres.

    # FR — Le maillage de tutoriel/maillage.py, tel quel ; `plot=False` : pas ses figures.
    # EN — tutoriel/maillage.py's mesh, as is; `plot=False`: without its figures.
    _, volume = structured_mesh(plot=False)

    # FR — Les charges réparties s'intègrent sur des faces : il faut la peau.
    # EN — Distributed loads integrate over faces: the skin is needed.
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))

C’est la bonne façon d’enchaîner deux calculs sur une même pièce : un maillage reconstruit à l’identique dans deux scripts donnerait deux jeux de nœuds distincts, et toute condition posée sur l’un serait sans effet sur l’autre. plot=False demande seulement de ne pas retracer les figures de la page Maillage.

La deuxième ligne prépare la suite. Les chargements répartis s’intègrent sur des faces et non sur des nœuds — c’est le \( [N]^T \) des intégrales ci-dessus : il leur faut de vraies mailles de bord, que pyrucast.mesh.skin extrait du volume d’hexaèdres. pyrucast.mesh.consolidate ramène cette peau à un seul sous-maillage, pour que les sélections qui suivent en renvoient un seul elles aussi.

Étape 1 — conduction seule

Le premier problème n’a que deux conditions aux limites : l’alésage tenu à 250 °C, et un flux sortant de −40 kW/m² sur la face gauche. Aucun numéro de nœud n’apparaît dans le script : les régions sont découpées géométriquement, ici par forme, avec la famille pyrucast.mesh.points_*.

L’alésage, sur un cylindre

    # FR — L'axe du trou : la normale du plan de la pièce (Y), par le centre.
    # EN — The hole's axis: the part plane's normal (Y), through the centre.
    bas_axe = [LENGTH, -THICKNESS, HEIGHT / 2.0]
    haut_axe = [LENGTH, 2.0 * THICKNESS, HEIGHT / 2.0]

    # FR — L'alésage : les nœuds sur le cylindre, lus à même le volume.
    # EN — The bore: the nodes on the cylinder, read straight off the volume.
    alesage = pc.mesh.consolidate(
        pc.mesh.points_on_cylinder(volume, bas_axe, haut_axe, HOLE_RADIUS)
    )

L’axe du trou se donne par deux points, débordant de part et d’autre de la pièce : la normale du plan de la pièce (\( Y \)), passant par le centre du demi-disque. points_on_cylinder retient alors les nœuds sur le cylindre de rayon HOLE_RADIUS — les disques d’extrémité sont laissés de côté, ce sont des faces planes et points_on_plane est là pour celles-là. Ce qui revient est donc exactement la paroi du trou, 320 nœuds : 120 sommets et 200 nœuds milieux, puisque le volume est quadratique.

Un blocage ne demande que des nœuds. La sélection se lit donc directement sur le volume, sans en extraire la peau : les nœuds de la paroi du trou sont des nœuds de bord par définition, et points_on_cylinder y trouve les mêmes 320 nœuds que sur la peau.

Un points_* renvoie déjà un maillage POI1. Le résultat est utilisable tel quel comme support d’un pc.model.dirichlet, sans passer par pyrucast.mesh.to_poi1. Seul mesh.consolidate reste nécessaire, pour écarter le sous-maillage vide que laisse la partie du volume qui ne touche pas le trou (le volume en compte deux : la grille et la couronne).

La face gauche, sur un plan

    # FR — La face gauche : les nœuds du plan x = 0, puis les QUA8 portés.
    # EN — The left face: the nodes of the plane x = 0, then the QUA8 they carry.
    noeuds_gauche = pc.mesh.points_on_plane(peau, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    face_gauche = pc.mesh.elements_on(peau, noeuds_gauche, strict=True)

Même principe, mais un flux s’intègre sur une surface : les nœuds ne suffisent pas. pyrucast.mesh.elements_on(..., strict=True) remonte des nœuds sélectionnés aux mailles dont tous les sommets sont retenus — ici les 20 QUA8 de la face gauche. Le plan donné à points_on_plane est infini, mais il ne coupe la peau qu’à cet endroit.

La figure des conditions aux limites

    # FR — Une couleur par région, la peau en fil de fer autour.
    # EN — One colour per region, the skin drawn as a wireframe around them.
    alesage.unit().face_color = BLEU
    face_gauche.unit().face_color = ROUGE
    show(
        peau | alesage | face_gauche,
        "Étape 1 — conditions aux limites",
        "thermique-cl-conduction.svg",
        wireframe=True,
    )

Une couleur par région, et la figure devient le schéma. face_color se pose sur le sous-maillage (unit() le désigne quand il n’y en a qu’un), la peau est tracée en fil de fer autour (wireframe=True) : la figure ci-dessus se lit comme le croquis des conditions aux limites, sans annotation manuelle.

Le modèle et son matériau

    # FR — Le modèle porte « T » (primal) et « q » (dual) sur tout le volume.
    # EN — The model carries "T" (primal) and "q" (dual) over the whole volume.
    fes = pc.FiniteElementSpace(volume)
    modele = pc.model.heat_conduction(fes)

    # FR — Dirichlet : le support bloqué ; la contrainte crée ses multiplicateurs.
    # EN — Dirichlet: the constrained support; the constraint mints its multipliers.
    blocage_T = pc.model.dirichlet(alesage, modele, "T")
    modele = modele | blocage_T

    # FR — Le flux imposé sur la face gauche est un terme du modèle.
    # EN — The imposed flux on the left face is a term of the model.
    gauche_fes = pc.FiniteElementSpace(face_gauche)
    modele = modele | pc.model.flux(gauche_fes, modele, "q")

    # FR — La conduction réclame « k », la charge sa densité.
    # EN — Conduction asks for "k", the load for its density.
    materiaux = pc.element_field.material_field(
        modele, [("k", K_COND), ("phi_q", FLUX_IMPOSE)]
    )

pc.model.heat_conduction déclare le couple de degrés de liberté « T » (primal) et « q » (dual) sur tout le volume et porte le terme \( \int_V [B]^T \lambda [B] \, dV \) ; pc.matrix.stiffness l’intègre réellement, avec le \( \lambda \) lu dans le champ matériau sous le nom « k ».

La température imposée, c’est la relation \( [A]\{T\} = \{T_{\text{imp}}\} \) du système par blocs posé plus haut. pc.model.dirichlet ne reçoit qu’un maillage POI1 : le support bloqué (l’alésage, tel que points_on_cylinder l’a renvoyé). Les inconnues \( \{\mu\} \), elles, ont besoin de leurs propres nœuds : la contrainte les crée elle-même, un par nœud bloqué et à la même position, et blocage_T.multiplier_mesh() rend le maillage qui les porte. Deux jeux de nœuds distincts, donc deux jeux d’inconnues : la solution renvoyée contient les deux, la température « T » et les multiplicateurs « lambda », dont l’opposé est la réaction.

Le flux imposé, lui, est un terme du modèle : pc.model.flux le déclare sur l’espace éléments finis de la face gauche, et | le réunit au reste.

Le champ matériau se construit à partir du modèle : material_field sait quels coefficients celui-ci réclame — « k » pour la conduction, et la densité « phi_q » pour le flux.

Les deux chargements

    flux_gauche = pc.node_field.external_forces(modele, materiaux)

    # FR — Température imposée, posée sur le maillage des multiplicateurs.
    # EN — Imposed temperature, set on the multipliers' mesh.
    temperature_imposee = pc.NodeField(blocage_T.multiplier_mesh(), ["imposed"])
    temperature_imposee[0].add_to_component("imposed", T_IMPOSEE)

Le flux imposé ne touche que le second membre : pc.node_field.external_forces intègre \( \int [N]^T \phi_{\text{imp}} \, dS \) sur les 20 QUA8 de la face gauche, avec la densité « phi_q » lue dans le champ matériau, et rend un champ nodal.

La température imposée, elle, se pose sur le maillage des multiplicateurs et non sur l’alésage lui-même : c’est le \( T_{\text{imp}} \) du second bloc du système par blocs, en face des inconnues \( \{\mu\} \).

Résolution

    # FR — `[K]{T} = {P}` : matrice assemblée, second membre réuni par `|`.
    # EN — `[K]{T} = {P}`: assembled matrix, right-hand side gathered by `|`.
    K = pc.matrix.stiffness(modele, materiaux)
    t_conduction = pc.solver.solve(K, flux_gauche | temperature_imposee)
    show_nodefield(
        volume, t_conduction, "Étape 1 — température (°C)", "thermique-conduction.svg"
    )

pc.matrix.stiffness intègre la matrice, | réunit les deux chargements — ils vivent sur des maillages disjoints, il n’y a donc rien à sommer — et pyrucast.solver.solve factorise la matrice creuse (LU parallèle) en mettant la factorisation en cache : deux résolutions sur la même matrice ne la factorisent qu’une fois.

Le résultat est le gradient attendu : 250 °C tenus à l’alésage, 32 °C sur la face gauche d’où la chaleur s’échappe.

Étape 2 — convection et source volumique

On ajoute maintenant les deux sollicitations restantes, sans rien retoucher aux précédentes : un film convectif sous la pièce, sur la face \( z = 0 \), et une tranche chauffée entre \( 0{,}33\,L \) et \( 0{,}51\,L \). Ces deux régions n’ont pas de forme simple à nommer : elles sont repérées par coordonnée, la seconde façon de découper une région.

La surface convectée, par coordonnée

    # FR — La face convectée, z = 0 : repérée par coordonnée, pas par forme.
    # EN — The convected face, z = 0: located by coordinate, not by shape.
    z_peau = pc.node_field.positions(peau, ["Z"])
    noeuds_bas = pc.mesh.select(z_peau, ge=-TOL, le=TOL)
    face_basse = pc.mesh.elements_on(peau, noeuds_bas, strict=True)
    face_basse.unit().face_color = TURQUOISE
    show(
        peau | face_basse,
        "Étape 2 — surface convectée",
        "thermique-cl-convection.svg",
        wireframe=True,
    )

Une coordonnée est un champ nodal comme un autre. pyrucast.node_field.positions(peau, ["Z"]) rend la coordonnée Z des nœuds de la peau sous forme de NodeField, et pyrucast.mesh.select garde ceux dont la valeur tombe dans une bande — ge=-TOL, le=TOL pour « z = 0 ». Le résultat est un maillage POI1, exactement comme celui d’un points_* : la suite ne change pas, elements_on(..., strict=True) remonte aux QUA8 que ces nœuds portent entièrement.

La zone chauffée, en bande

    # FR — La zone chauffée : même démarche sur X, en bande, et sur le volume.
    # EN — The heated zone: same approach on X, as a band, over the volume.
    x_volume = pc.node_field.positions(volume, ["X"])
    noeuds_source = pc.mesh.select(x_volume, ge=SOURCE_X_MIN, le=SOURCE_X_MAX)
    zone_source = pc.mesh.consolidate(
        pc.mesh.elements_on(volume, noeuds_source, strict=True)
    )
    zone_source.unit().face_color = VERT
    show(
        peau | zone_source,
        "Étape 2 — zone chauffée",
        "thermique-cl-source.svg",
        wireframe=True,
    )

Même démarche, mais sur X et sur le volume : une bande de valeurs au lieu d’une égalité, et des HEX20 au lieu de QUA8. Comme pour l’alésage, mesh.consolidate écarte le sous-maillage vide laissé par la partie du volume qui ne rencontre pas la bande.

strict=True approche la région par un escalier. La bande en X coupe le maillage entre deux abscisses quelconques, mais ce qui est retenu est le paquet des 80 hexaèdres dont tous les nœuds sont dedans : la tranche s’arrête donc aux frontières des éléments, bien visible sur la figure. C’est le prix à payer pour que la région chargée soit un sous-maillage conforme.

Le modèle complet

    # FR — La convection s'ajoute dans la matrice : `|` sur les mêmes ddl.
    # EN — Convection adds into the matrix: `|` on the very same dofs.
    basse_fes = pc.FiniteElementSpace(face_basse)
    conduction = pc.model.heat_conduction(fes)
    modele = conduction | pc.model.boundary_transfer(basse_fes, conduction, ["T"])

    # FR — Le blocage de l'étape 1, repris tel quel avec ses multiplicateurs.
    # EN — Step 1's constraint, taken back as is with its multipliers.
    modele = modele | blocage_T

    # FR — Un seul champ matériau : « k » pour la conduction, « h » et son
    #      ambiant pour le film.
    # EN — A single material field: "k" for conduction, "h" and its ambient for
    #      the film.
    materiaux = pc.element_field.material_field(
        modele, [("k", K_COND), ("h_T", H_CONV), ("a_ext_T", T_EXT)]
    )

La convection est la seule des quatre sollicitations à toucher les deux membres du système, parce que \( \phi \cdot n = h\,(T - T_f) \) dépend de l’inconnue. Sa part en \( T \) donne \( \int h\,[N]^T[N] \, dS \), qui s’ajoute dans la matrice : ce n’est pas un système séparé, d’où le pc.model.boundary_transfer(basse_fes, conduction, ["T"]), construit contre la conduction puis réuni à elle par |, sur les mêmes degrés de liberté « T » et « q ». Le blocage de l’étape 1, blocage_T, est repris tel quel, avec ses multiplicateurs : la température imposée posée sur eux à l’étape 1 reste donc valable.

Un seul material_field couvre le tout — « k » est réclamé par la conduction, « h_T » (le coefficient d’échange) et « a_ext_T » (la température du fluide) par la convection.

Les deux nouveaux chargements

    # FR — Terme externe de la convection, h·T_ext : le modèle le porte.
    # EN — The convection's external term, h·T_ext: the model carries it.
    charge_convection = pc.node_field.external_forces(modele, materiaux)

    # FR — Source volumique sur des HEX20, donc une densité volumique. Une
    #      charge ne contribuant à aucune matrice, elle se tient très bien en
    #      modèle à elle seule — avec sa propre densité, distincte de celle du
    #      flux de bord bien qu'elles alimentent la même ligne « q ».
    # EN — A volume source over HEX20 cells. A load contributes to no matrix, so
    #      it stands perfectly well as a model of its own — with its own
    #      density, distinct from the boundary flux's though both feed "q".
    source_fes = pc.FiniteElementSpace(zone_source)
    source = pc.model.flux(source_fes, modele, "q")
    densite_source = pc.element_field.material_field(
        source, [("phi_q", SOURCE_VOLUMIQUE)]
    )
    charge_source = pc.node_field.external_forces(source, densite_source)

La part en \( T_f \) de la convection donne \( \int h\,T_f\,[N]^T \, dS \), un second membre ordinaire : le terme boundary_transfer le porte déjà, et le même appel pc.node_field.external_forces que pour le flux imposé le calcule, avec « h_T » et « a_ext_T » lus dans le champ matériau.

La source volumique, elle, est le terme \( \int_V [N]^T q \, dV \) : encore flux, mais appliqué à des HEX20. La dimension de l’intégrale est celle des éléments qu’on lui donne, donc une densité volumique ici. Un chargement ne contribue à aucune matrice : il forme ici un modèle à lui seul, source, avec son propre champ matériau, parce que sa densité « phi_q » n’est pas celle du flux de la face gauche.

Trois chargements qui se touchent : le second membre se somme

Le bas de la face gauche est sur \( z = 0 \), et la tranche chauffée débouche elle aussi sous la pièce : les trois chargements répartis partagent des nœuds. Leurs contributions doivent donc s’additionner là — et c’est précisément ce que l’union | ne fait pas.

Piège : deux régions chargées adjacentes. Leurs contributions nodales ne sont pas sommées automatiquement à l’assemblage : chaque chargement est assemblé sur son propre support, et l’union (|) juxtapose ces supports sans les additionner. À un nœud partagé, le solveur lit le second membre zone par zone et retient la valeur de la première qui définit le couple (nœud, composante) — l’autre contribution est perdue. L’union ne lève une erreur que si les deux valeurs diffèrent ; quand elles coïncident, elle passe sans rien dire.

Sommer les champs (+) ne suffit pas non plus tel quel : l’arithmétique de champs apparie elle aussi les zones par support. Deux supports distincts ne sont jamais additionnés : chaque zone passe telle quelle, et quand deux zones donneraient deux valeurs différentes à un nœud partagé, l’opération s’arrête sur une erreur qui renvoie vers restrict_like. Il faut d’abord ramener les champs sur un support commun — pyrucast.node_field.restrict sur un même maillage retombe sur le support POI1 canonique de ce maillage, donc restrict(a, m) + restrict(b, m) est bien une somme nœud à nœud (la page Champ détaille cette algèbre ; la page Dilatation, matériau homogène en donne un exemple avec restrict_like).

    # FR — Les trois charges se touchent : support commun, puis `+` somme.
    # EN — The three loads touch: a common support first, then `+` really sums.
    noeuds_charges = pc.mesh.consolidate(
        pc.mesh.to_poi1(face_gauche | face_basse | zone_source)
    )
    second_membre = (
        pc.node_field.restrict(flux_gauche, noeuds_charges)
        + pc.node_field.restrict(charge_convection, noeuds_charges)
        + pc.node_field.restrict(charge_source, noeuds_charges)
    ) | temperature_imposee

D’où ces quelques lignes : le maillage POI1 de tous les nœuds chargés (to_poi1 puis mesh.consolidate, pour n’avoir qu’un seul sous-maillage donc un seul support), les trois champs restreints dessus, et leur somme par +. La vérification est immédiate — la puissance totale du second membre, 64,8 W, vaut la somme des trois puissances prises séparément (−80 W de flux sortant, −115,2 W de convection, 260 W de source). L’union, elle, n’irait pas jusque-là : aux nœuds partagés les valeurs diffèrent, et elle s’arrêterait sur une erreur.

La température imposée, elle, vit sur le maillage des multiplicateurs, dont les nœuds, créés par la contrainte, n’appartiennent à aucun autre maillage : elle se réunit au reste par | sans rien avoir à sommer.

Résolution

    # FR — Même schéma qu'à l'étape 1, sur la matrice enrichie du terme convectif.
    # EN — Same pattern as step 1, on the matrix enriched with the film term.
    K = pc.matrix.stiffness(modele, materiaux)
    t_complet = pc.solver.solve(K, second_membre)
    show_nodefield(
        volume, t_complet, "Étape 2 — température (°C)", "thermique-complet.svg"
    )

Rien de nouveau par rapport à l’étape 1 : la même paire stiffness / solve, sur une matrice qui porte en plus le terme convectif.

La lecture du résultat suit les quatre chargements : l’alésage est toujours tenu à 250 °C par le blocage et la face gauche reste le point froid sous le flux sortant, mais les isothermes, rectilignes à l’étape 1, s’infléchissent maintenant autour de la tranche chauffée. Les deux nouvelles sollicitations jouent en sens contraire — la source réchauffe la moitié gauche (le minimum remonte de 32 à 40 °C), le film convectif pompe la chaleur sous la pièce — et avec les cotes de la page Maillage c’est la convection qui l’emporte : rien ne passe au-dessus de la température de l’alésage.

Ce régime établi est celui que la pièce atteint quand les sollicitations durent : la page Régime transitoire part d’une pièce à 0 °C, installe les mêmes chargements au fil du temps, et s’approche de ce champ au dernier instant.

Script complet

Une fois les explications retirées, tout tient en une page — c’est le déroulé complet, du maillage importé aux deux résolutions :

"""Tutoriel débutant — 2. Régime stationnaire. / Beginner tutorial — 2. Steady state.

FR — Reprend la **chape percée** de la page « Maillage » — le volume HEX20
structuré est importé de `tutoriel/maillage.py`, aucune cote n'est redonnée —
et y résout la conduction **stationnaire** `div(-k·grad T) = q`, en deux temps :

1. **conduction seule** — température imposée sur l'alésage, flux imposé sur la
   face gauche ;
2. **conduction + convection + source** — film convectif sous la pièce et
   tranche chauffée, sans rien retoucher au reste.

Chaque étape est tracée avant d'être résolue, et les régions chargées sont
repérées **par leur géométrie** : par forme (`pyrucast.mesh.points_*`) ou par
coordonnée (`pyrucast.node_field.positions` + `pyrucast.mesh.select`). Ce
script ne traite ni rayonnement ni terme transitoire : ce sont les scripts
`tutoriel/thermique_transitoire.py` et `tutoriel/thermique_non_lineaire.py`.
Le détail pas à pas est dans le livre, page « Régime stationnaire ».

EN — Picks the meshing page's **pierced lug** back up — the structured HEX20
volume is imported from `tutoriel/maillage.py`, not one dimension is restated —
and solves **steady** conduction `div(-k·grad T) = q` on it, in two steps:

1. **conduction alone** — imposed temperature on the bore, imposed flux on the
   left face;
2. **conduction + convection + source** — convective film under the part and
   heated slice, nothing else changes.

Each step is plotted before being solved, and the loaded regions are located
**by geometry**: by shape (`pyrucast.mesh.points_*`) or by coordinate
(`pyrucast.node_field.positions` + `pyrucast.mesh.select`). This script handles
neither radiation nor a transient term: those are
`tutoriel/thermique_transitoire.py` and `tutoriel/thermique_non_lineaire.py`.
The step-by-step walkthrough lives in the book's steady-state page.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/thermique.py

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

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)

# FR — Une couleur par région chargée, tenue d'une figure à l'autre.
# EN — One colour per loaded region, kept from one figure to the next.
BLEU = (0, 0, 255)  # alésage / bore
ROUGE = (255, 0, 0)  # face gauche / left face
TURQUOISE = (0, 190, 190)  # face convectée / convected face
VERT = (0, 170, 0)  # zone chauffée / heated zone

# ── Données physiques / Physical data ──────────────────────────────────────
K_COND = 50.0  # W/m/K
FLUX_IMPOSE = -40_000.0  # W/m², face gauche / left face
H_CONV, T_EXT = 240.0, -80.0  # W/m²/K, °C — convection, face z = 0
SOURCE_VOLUMIQUE = 2600e3  # W/m³, zone chauffée / heated zone (≈ 260 W)
T_IMPOSEE = 250.0  # °C, alésage / bore

# FR — La zone chauffée est une tranche de la pièce, entre deux abscisses.
# EN — The heated zone is a slice of the part, between two abscissae.
SOURCE_X_MIN, SOURCE_X_MAX = 0.33 * LENGTH, 0.51 * LENGTH

# FR — Une face plane vaut zéro à l'arrondi près : on sélectionne une bande.
# EN — A flat face is zero up to rounding: a band is selected, not a value.
TOL = 1e-9  # m


def show_nodefield(mesh: pc.Mesh, field: pc.NodeField, title: str, file: str) -> None:
    """FR — Trace `field` sur `mesh` : fenêtre interactive, ou SVG si `OUT`.

    EN — Plot `field` over `mesh`: interactive window, or SVG when `OUT` is set.
    """
    mesh.plot(
        view=VUE,
        title=title,
        field=field,
        component="T",
        cmap="viridis",
        smooth=1,
        save=os.path.join(OUT, file) if OUT else None,
    )


def main() -> None:
    # FR — Le maillage de tutoriel/maillage.py, tel quel ; `plot=False` : pas ses figures.
    # EN — tutoriel/maillage.py's mesh, as is; `plot=False`: without its figures.
    _, volume = structured_mesh(plot=False)

    # FR — Les charges réparties s'intègrent sur des faces : il faut la peau.
    # EN — Distributed loads integrate over faces: the skin is needed.
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))

    # ── Étape 1 : régions / Step 1: regions ─────────────────────────────────
    # FR — L'axe du trou : la normale du plan de la pièce (Y), par le centre.
    # EN — The hole's axis: the part plane's normal (Y), through the centre.
    bas_axe = [LENGTH, -THICKNESS, HEIGHT / 2.0]
    haut_axe = [LENGTH, 2.0 * THICKNESS, HEIGHT / 2.0]

    # FR — L'alésage : les nœuds sur le cylindre, lus à même le volume.
    # EN — The bore: the nodes on the cylinder, read straight off the volume.
    alesage = pc.mesh.consolidate(
        pc.mesh.points_on_cylinder(volume, bas_axe, haut_axe, HOLE_RADIUS)
    )

    # FR — La face gauche : les nœuds du plan x = 0, puis les QUA8 portés.
    # EN — The left face: the nodes of the plane x = 0, then the QUA8 they carry.
    noeuds_gauche = pc.mesh.points_on_plane(peau, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    face_gauche = pc.mesh.elements_on(peau, noeuds_gauche, strict=True)

    # FR — Une couleur par région, la peau en fil de fer autour.
    # EN — One colour per region, the skin drawn as a wireframe around them.
    alesage.unit().face_color = BLEU
    face_gauche.unit().face_color = ROUGE
    show(
        peau | alesage | face_gauche,
        "Étape 1 — conditions aux limites",
        "thermique-cl-conduction.svg",
        wireframe=True,
    )

    # ── Étape 1 : calcul / Step 1: analysis ─────────────────────────────────
    # FR — Le modèle porte « T » (primal) et « q » (dual) sur tout le volume.
    # EN — The model carries "T" (primal) and "q" (dual) over the whole volume.
    fes = pc.FiniteElementSpace(volume)
    modele = pc.model.heat_conduction(fes)

    # FR — Dirichlet : le support bloqué ; la contrainte crée ses multiplicateurs.
    # EN — Dirichlet: the constrained support; the constraint mints its multipliers.
    blocage_T = pc.model.dirichlet(alesage, modele, "T")
    modele = modele | blocage_T

    # FR — Le flux imposé sur la face gauche est un terme du modèle.
    # EN — The imposed flux on the left face is a term of the model.
    gauche_fes = pc.FiniteElementSpace(face_gauche)
    modele = modele | pc.model.flux(gauche_fes, modele, "q")

    # FR — La conduction réclame « k », la charge sa densité.
    # EN — Conduction asks for "k", the load for its density.
    materiaux = pc.element_field.material_field(
        modele, [("k", K_COND), ("phi_q", FLUX_IMPOSE)]
    )

    flux_gauche = pc.node_field.external_forces(modele, materiaux)

    # FR — Température imposée, posée sur le maillage des multiplicateurs.
    # EN — Imposed temperature, set on the multipliers' mesh.
    temperature_imposee = pc.NodeField(blocage_T.multiplier_mesh(), ["imposed"])
    temperature_imposee[0].add_to_component("imposed", T_IMPOSEE)

    # FR — `[K]{T} = {P}` : matrice assemblée, second membre réuni par `|`.
    # EN — `[K]{T} = {P}`: assembled matrix, right-hand side gathered by `|`.
    K = pc.matrix.stiffness(modele, materiaux)
    t_conduction = pc.solver.solve(K, flux_gauche | temperature_imposee)
    show_nodefield(
        volume, t_conduction, "Étape 1 — température (°C)", "thermique-conduction.svg"
    )

    # ── Étape 2 : régions / Step 2: regions ─────────────────────────────────
    # FR — La face convectée, z = 0 : repérée par coordonnée, pas par forme.
    # EN — The convected face, z = 0: located by coordinate, not by shape.
    z_peau = pc.node_field.positions(peau, ["Z"])
    noeuds_bas = pc.mesh.select(z_peau, ge=-TOL, le=TOL)
    face_basse = pc.mesh.elements_on(peau, noeuds_bas, strict=True)
    face_basse.unit().face_color = TURQUOISE
    show(
        peau | face_basse,
        "Étape 2 — surface convectée",
        "thermique-cl-convection.svg",
        wireframe=True,
    )

    # FR — La zone chauffée : même démarche sur X, en bande, et sur le volume.
    # EN — The heated zone: same approach on X, as a band, over the volume.
    x_volume = pc.node_field.positions(volume, ["X"])
    noeuds_source = pc.mesh.select(x_volume, ge=SOURCE_X_MIN, le=SOURCE_X_MAX)
    zone_source = pc.mesh.consolidate(
        pc.mesh.elements_on(volume, noeuds_source, strict=True)
    )
    zone_source.unit().face_color = VERT
    show(
        peau | zone_source,
        "Étape 2 — zone chauffée",
        "thermique-cl-source.svg",
        wireframe=True,
    )

    # ── Étape 2 : calcul / Step 2: analysis ─────────────────────────────────
    # FR — La convection s'ajoute dans la matrice : `|` sur les mêmes ddl.
    # EN — Convection adds into the matrix: `|` on the very same dofs.
    basse_fes = pc.FiniteElementSpace(face_basse)
    conduction = pc.model.heat_conduction(fes)
    modele = conduction | pc.model.boundary_transfer(basse_fes, conduction, ["T"])

    # FR — Le blocage de l'étape 1, repris tel quel avec ses multiplicateurs.
    # EN — Step 1's constraint, taken back as is with its multipliers.
    modele = modele | blocage_T

    # FR — Un seul champ matériau : « k » pour la conduction, « h » et son
    #      ambiant pour le film.
    # EN — A single material field: "k" for conduction, "h" and its ambient for
    #      the film.
    materiaux = pc.element_field.material_field(
        modele, [("k", K_COND), ("h_T", H_CONV), ("a_ext_T", T_EXT)]
    )

    # FR — Terme externe de la convection, h·T_ext : le modèle le porte.
    # EN — The convection's external term, h·T_ext: the model carries it.
    charge_convection = pc.node_field.external_forces(modele, materiaux)

    # FR — Source volumique sur des HEX20, donc une densité volumique. Une
    #      charge ne contribuant à aucune matrice, elle se tient très bien en
    #      modèle à elle seule — avec sa propre densité, distincte de celle du
    #      flux de bord bien qu'elles alimentent la même ligne « q ».
    # EN — A volume source over HEX20 cells. A load contributes to no matrix, so
    #      it stands perfectly well as a model of its own — with its own
    #      density, distinct from the boundary flux's though both feed "q".
    source_fes = pc.FiniteElementSpace(zone_source)
    source = pc.model.flux(source_fes, modele, "q")
    densite_source = pc.element_field.material_field(
        source, [("phi_q", SOURCE_VOLUMIQUE)]
    )
    charge_source = pc.node_field.external_forces(source, densite_source)

    # FR — Les trois charges se touchent : support commun, puis `+` somme.
    # EN — The three loads touch: a common support first, then `+` really sums.
    noeuds_charges = pc.mesh.consolidate(
        pc.mesh.to_poi1(face_gauche | face_basse | zone_source)
    )
    second_membre = (
        pc.node_field.restrict(flux_gauche, noeuds_charges)
        + pc.node_field.restrict(charge_convection, noeuds_charges)
        + pc.node_field.restrict(charge_source, noeuds_charges)
    ) | temperature_imposee

    # FR — Même schéma qu'à l'étape 1, sur la matrice enrichie du terme convectif.
    # EN — Same pattern as step 1, on the matrix enriched with the film term.
    K = pc.matrix.stiffness(modele, materiaux)
    t_complet = pc.solver.solve(K, second_membre)
    show_nodefield(
        volume, t_complet, "Étape 2 — température (°C)", "thermique-complet.svg"
    )

    print(f"volume         : {volume.cell_count} HEX20")
    print(f"face gauche    : {face_gauche.cell_count} QUA8")
    print(f"face convectée : {face_basse.cell_count} QUA8")
    print(f"zone chauffée  : {zone_source.cell_count} HEX20")
    print(
        f"étape 1 : T min = {t_conduction.min('T'):.1f} °C, "
        f"T max = {t_conduction.max('T'):.1f} °C"
    )
    print(
        f"étape 2 : T min = {t_complet.min('T'):.1f} °C, "
        f"T max = {t_complet.max('T'):.1f} °C"
    )


if __name__ == "__main__":
    main()

Suite : Régime transitoire.

Régime transitoire

Reprend la chape percée et les sollicitations de la page Régime stationnaire, mais sur une pièce à 0 °C au départ, et avec des sollicitations qui s’établissent au fil du temps : l’alésage monte à 250 °C pendant les 1 500 premières secondes, le flux de la face gauche et la source s’installent entre 4 500 s et 7 500 s, la convection agit dès le début. Le calcul suit la pièce sur 15 000 s, en 100 pas de temps, puis calcule le flux de chaleur au dernier instant.

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

L’équation résolue

La page Thermique linéaire a posé la forme discrétisée de l’équation de la chaleur. En transitoire, le terme de capacité reste : on résout le système d’équations différentielles

\[ [C]\{\dot{T}\} + [K]\{T\} = \{P(t)\}, \qquad \{T(0)\} = \{T_0\} \]

\[ [C] = \int_V \rho c_p\,[N]^T [N] \, dV \]

où \( [K] \) est celle du stationnaire — conduction et film convectif — et où le second membre suit les rampes :

\[ \{P(t)\} = r(t)\,\big(\{P_{\text{flux}}\} + \{P_{\text{source}}\}\big) + \{P_{\text{convection}}\}, \qquad T_{\text{imp}}(t) = s(t) \times 250\ \text{°C} \]

\( r \) et \( s \) vont de 0 à 1 ; \( \{P_{\text{convection}}\} \), la part en \( h\,T_f \), ne dépend pas du temps.

Le temps avance pas à pas. Entre deux instants \( t_n \) et \( t_{n+1} = t_n + \Delta t \), pyrucast.stepping remplace \( \{\dot{T}\} \) par \( (\{T_{n+1}\} - \{T_n\}) / \Delta t \) et écrit l’équilibre à la fin du pas (le schéma θ avec \( \theta = 1 \), son réglage par défaut). Avec les multiplicateurs \( \{\mu\} \) de la température imposée, c’est à chaque pas le système

\[ \begin{bmatrix} \frac{C}{\Delta t} + K & A^T \\ A & 0 \end{bmatrix} \begin{Bmatrix} T_{n+1} \\ \mu \end{Bmatrix} = \begin{Bmatrix} \frac{C}{\Delta t} T_n + P(t_{n+1}) \\ T_{\text{imp}}(t_{n+1}) \end{Bmatrix} \]

que le calcul pas à pas résout. Il le fait en corrections successives, comme il le ferait pour un problème non linéaire : il part de \( T_n \), calcule le résidu \( \{P(t_{n+1})\} - [K]\{T\} - \frac{[C]}{\Delta t}(\{T\} - \{T_n\}) \), résout la correction avec la matrice \( \frac{C}{\Delta t} + K \), et s’arrête quand la correction tombe sous \( 10^{-4} \) fois la température. Ici tout est linéaire : la première correction donne la solution, la seconde est nulle — 2 itérations par pas, pas une de plus.

Chaque terme correspond à un objet du script :

Termepyrucast
\( [K] \)pc.model.heat_conduction et pc.model.boundary_transfer, comme au stationnaire
\( [C] \)les coefficients « rho » et « cp » du champ matériau, que pyrucast.matrix.mass assemble
\( r(t)\,\{P_{\text{flux}} + P_{\text{source}}\} \)deux termes pc.model.flux, dont la densité « phi_q » suit une pc.Evolution de champs matériau
\( T_{\text{imp}}(t) \)pc.model.dirichlet et une pc.Evolution de la température imposée
\( \{T_0\} \)le champ initial donné au calcul
le pas de tempspc.stepping.step_by_step, sur la liste des instants

Les données du calcul

# FR — Les données du stationnaire sont importées de tutoriel/thermique.py ;
#      le transitoire ajoute la capacité de l'acier et le temps.
# EN — The steady-state data come from tutoriel/thermique.py; the transient
#      adds the steel's capacity and the time.
RHO = 7800.0  # kg/m³
CP = 420.0  # J/kg/K
T_INITIALE = 0.0  # °C, toute la pièce au départ / the whole part at start

T_FINAL = 15000.0  # s
N_PAS = 100  # pas de temps / time steps

# FR — Les rampes : l'alésage atteint 250 °C à 0,1 t_f ; le flux de la face
#      gauche et la source s'installent de 0,3 t_f à 0,5 t_f.
# EN — The ramps: the bore reaches 250 °C at 0.1 t_f; the left face's flux
#      and the source set in from 0.3 t_f to 0.5 t_f.
T_ALESAGE_CHAUD = 0.1 * T_FINAL  # s
T_DEBUT_CHARGES = 0.3 * T_FINAL  # s
T_PLEINES_CHARGES = 0.5 * T_FINAL  # s

# FR — Les quatre points suivis, à mi-épaisseur.
# EN — The four points followed, at mid-thickness.
POINT_A = [0.0, THICKNESS / 2.0, 0.0]  # coin bas gauche / lower left corner
POINT_B = [0.45 * LENGTH, THICKNESS / 2.0, HEIGHT]  # bord du haut / top edge
POINT_C = [
    0.8 * LENGTH,
    THICKNESS / 2.0,
    HEIGHT / 2.0,
]  # avant le trou / before the hole
POINT_D = [LENGTH + HEIGHT / 2.0, THICKNESS / 2.0, HEIGHT / 2.0]  # la pointe / the tip

# FR — Les instants tracés, par leur rang : 1 500 s, 6 000 s, 15 000 s.
# EN — The instants plotted, by rank: 1,500 s, 6,000 s, 15,000 s.
PAS_TRACES = [10, 40, 100]

Les données du stationnaire — conductivité, flux, convection, source, température de l’alésage — sont importées de tutoriel/thermique.py, et ne sont donc écrites qu’une fois. Le transitoire n’ajoute que ce qui lui est propre : la capacité de l’acier (\( \rho = 7\,800 \) kg/m³, \( c_p = 420 \) J/kg/K), la température de départ, la durée et le nombre de pas, les instants des rampes, et les quatre points dont on suivra la température. Avec 100 pas sur 15 000 s, \( \Delta t = 150 \) s.

On reprend la pièce

    # FR — Le maillage de tutoriel/maillage.py, tel quel, et sa peau.
    # EN — tutoriel/maillage.py's mesh, as is, and its skin.
    _, volume = structured_mesh(plot=False)
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))

Le maillage est celui de la page Maillage, importé comme au stationnaire : 640 HEX20.

    # FR — Les régions de la page « Régime stationnaire », redécoupées à
    #      l'identique : l'alésage, la face gauche, la face basse, la tranche.
    # EN — The regions of the steady-state page, cut out again the same way:
    #      the bore, the left face, the bottom face, the slice.
    bas_axe = [LENGTH, -THICKNESS, HEIGHT / 2.0]
    haut_axe = [LENGTH, 2.0 * THICKNESS, HEIGHT / 2.0]
    alesage = pc.mesh.consolidate(
        pc.mesh.points_on_cylinder(volume, bas_axe, haut_axe, HOLE_RADIUS)
    )

    noeuds_gauche = pc.mesh.points_on_plane(peau, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    face_gauche = pc.mesh.elements_on(peau, noeuds_gauche, strict=True)

    z_peau = pc.node_field.positions(peau, ["Z"])
    noeuds_bas = pc.mesh.select(z_peau, ge=-TOL, le=TOL)
    face_basse = pc.mesh.elements_on(peau, noeuds_bas, strict=True)

    x_volume = pc.node_field.positions(volume, ["X"])
    noeuds_source = pc.mesh.select(x_volume, ge=SOURCE_X_MIN, le=SOURCE_X_MAX)
    zone_source = pc.mesh.consolidate(
        pc.mesh.elements_on(volume, noeuds_source, strict=True)
    )

Les régions se redécoupent à l’identique. Ce sont les mêmes lignes que dans tutoriel/thermique.py — l’alésage par forme, la face gauche par plan, la face basse et la tranche par coordonnée —, et elles retrouvent les mêmes nœuds et les mêmes mailles : le maillage est le même objet, importé de la même fonction. Les recopier garde ce script lisible d’un bout à l’autre, sans aller chercher ailleurs ce qu’il calcule.

    # FR — Le modèle complet d'un tenant : conduction, convection, blocage de
    #      l'alésage, puis les deux termes de flux.
    # EN — The whole model at once: conduction, convection, the bore's
    #      constraint, then the two flux terms.
    fes = pc.FiniteElementSpace(volume)
    conduction = pc.model.heat_conduction(fes)
    basse_fes = pc.FiniteElementSpace(face_basse)
    modele = conduction | pc.model.boundary_transfer(basse_fes, conduction, ["T"])
    modele = modele | pc.model.dirichlet(alesage, conduction, "T")

    flux_gauche = pc.model.flux(pc.FiniteElementSpace(face_gauche), modele, "q")
    source = pc.model.flux(pc.FiniteElementSpace(zone_source), modele, "q")

Le modèle réunit d’un coup ce que le stationnaire construisait en deux temps : conduction, film convectif, blocage de l’alésage, flux de la face gauche et source. flux_gauche et source restent nommés à part, parce que leur densité « phi_q » n’est pas la même : chacun reçoit son champ matériau.

Les chargements dans le temps

Les sollicitations suivent deux rampes :

  T imposée (alésage)              flux de la face gauche et source
  250 °C ┤      ┌──────────        pleins ┤              ┌──────────
         │     ╱                          │             ╱
         │    ╱                           │            ╱
    0 °C ┼───┘                    éteints ┼───────────┘
         0   1 500     15 000 s           0    4 500  7 500   15 000 s

Les matériaux

    # FR — Deux champs matériau construits de la même façon : les
    #      sollicitations éteintes (phi_q = 0), puis pleines. La capacité
    #      « rho » × « cp » est ce que le transitoire ajoute.
    # EN — Two material fields built the same way: the loads off
    #      (phi_q = 0), then on. The capacity "rho" × "cp" is what the
    #      transient adds.
    eteint = (
        pc.element_field.material_field(
            modele,
            [
                ("k", K_COND),
                ("h_T", H_CONV),
                ("a_ext_T", T_EXT),
                ("rho", RHO),
                ("cp", CP),
            ],
        )
        | pc.element_field.material_field(flux_gauche, [("phi_q", 0.0)])
        | pc.element_field.material_field(source, [("phi_q", 0.0)])
    )
    plein = (
        pc.element_field.material_field(
            modele,
            [
                ("k", K_COND),
                ("h_T", H_CONV),
                ("a_ext_T", T_EXT),
                ("rho", RHO),
                ("cp", CP),
            ],
        )
        | pc.element_field.material_field(flux_gauche, [("phi_q", FLUX_IMPOSE)])
        | pc.element_field.material_field(source, [("phi_q", SOURCE_VOLUMIQUE)])
    )

    # FR — L'évolution reste éteinte jusqu'à 0,3 t_f, puis passe à pleine à
    #      0,5 t_f ; entre deux instants, elle interpole.
    # EN — The evolution stays off until 0.3 t_f, then turns on by 0.5 t_f;
    #      between two instants, it interpolates.
    materiaux = pc.Evolution(
        [
            (0.0, eteint),
            (T_DEBUT_CHARGES, eteint),
            (T_PLEINES_CHARGES, plein),
            (T_FINAL, plein),
        ]
    )

Un chargement qui varie, c’est un champ matériau qui varie. Le flux et la source sont des termes du modèle dont la densité « phi_q » est un coefficient du champ matériau : pour les faire monter, il suffit de donner au calcul une évolution de champs matériau. Les deux champs eteint et plein sont construits de la même façon, ne diffèrent que par « phi_q », et pc.Evolution les range dans le temps ; entre deux instants donnés, elle interpole linéairement. Comme le second membre est proportionnel à « phi_q », il suit la rampe \( r(t) \).

Les deux champs portent aussi « rho » et « cp » : c’est ce qui fait de ce calcul un transitoire. Sans capacité, le calcul pas à pas résoudrait un stationnaire à chaque instant.

La température imposée

    # FR — La température imposée, posée sur les multiplicateurs comme au
    #      stationnaire : 0 °C au départ, 250 °C dès 0,1 t_f.
    # EN — The imposed temperature, set on the multipliers as in the steady
    #      state: 0 °C at the start, 250 °C from 0.1 t_f on.
    froide = pc.NodeField(modele.multiplier_mesh(), ["imposed"]) + T_INITIALE
    chaude = pc.NodeField(modele.multiplier_mesh(), ["imposed"]) + T_IMPOSEE
    chargement = pc.Evolution(
        [(0.0, froide), (T_ALESAGE_CHAUD, chaude), (T_FINAL, chaude)]
    )

Comme au stationnaire, la température imposée se pose sur le maillage des multiplicateurs que la contrainte a créés, modele.multiplier_mesh(). Deux champs, 0 °C et 250 °C, et une évolution entre eux : c’est la rampe \( s(t) \). La pièce part à 0 °C, l’alésage aussi.

Le calcul pas à pas

    # FR — Les instants calculés : 0, 150, 300… 15 000 s.
    # EN — The instants computed: 0, 150, 300… 15,000 s.
    instants = []
    for i in range(N_PAS + 1):
        instants.append(i * T_FINAL / N_PAS)

    # FR — Le calcul se décrit par un dictionnaire, puis se lance.
    # EN — The analysis is described by a dictionary, then run.
    data = {
        "model": modele | flux_gauche | source,
        "times": instants,
        "materials": materiaux,
        "loads": chargement,
        "initial": {"temperature": pc.NodeField(volume, ["T"]) + T_INITIALE},
    }
    pc.stepping.step_by_step(data)
    resultats = data["results"]

Un dictionnaire décrit le calcul. pc.stepping.step_by_step le lit : le modèle, les instants à calculer, les matériaux, les chargements et l’état initial. Le modèle est le modèle complet, flux compris : le calcul pas à pas intègre lui-même, à chaque instant, les chargements que le modèle porte, avec les matériaux de l’instant. loads ne reçoit donc que la température imposée.

Les résultats sont rangés dans data["results"], un enregistrement par instant, l’état initial compris : 101 enregistrements, dont chacun porte son instant ("time"), le champ de température ("temperature") et le nombre d’itérations du pas ("iterations", 2 au plus, comme annoncé).

Les résultats

Quatre points suivis dans le temps

    # FR — Les nœuds les plus proches des quatre points suivis.
    # EN — The nodes closest to the four points followed.
    a = volume.nearest_node(POINT_A)
    b = volume.nearest_node(POINT_B)
    c = volume.nearest_node(POINT_C)
    d = volume.nearest_node(POINT_D)

    # FR — Une liste de couples (temps, température) par point, remplie
    #      instant après instant.
    # EN — One list of (time, temperature) pairs per point, filled instant
    #      after instant.
    courbe_a, courbe_b, courbe_c, courbe_d = [], [], [], []
    for resultat in resultats:
        t, temperature = resultat["time"], resultat["temperature"]
        courbe_a.append((t, temperature.value(a, "T")))
        courbe_b.append((t, temperature.value(b, "T")))
        courbe_c.append((t, temperature.value(c, "T")))
        courbe_d.append((t, temperature.value(d, "T")))

    # FR — Quatre courbes réunies par `|` : une ligne par courbe sur la figure.
    # EN — Four curves gathered by `|`: one line per curve on the figure.
    courbes = (
        pc.SubEvolution(courbe_a)
        | pc.SubEvolution(courbe_b)
        | pc.SubEvolution(courbe_c)
        | pc.SubEvolution(courbe_d)
    )
    courbes.plot(
        x_label="temps (s)",
        y_label="T (°C)",
        title="Température en A, B, C, D",
        save=os.path.join(OUT, "thermique-transitoire-courbes.svg") if OUT else None,
    )

nearest_node donne le nœud du volume le plus proche d’un point ; on y lit la température à chaque instant, et chaque point donne une liste de couples (temps, température). Une pc.SubEvolution en fait une courbe, et | les réunit : tracée, l’évolution montre une ligne par courbe. La légende les numérote dans l’ordre de l’union — zone 0 pour A, zone 1 pour B, zone 2 pour C, zone 3 pour D.

Chaque courbe raconte un chargement :

  • D, la pointe, est à deux pas de l’alésage : elle suit sa rampe et touche presque 250 °C dès 1 500 s ;
  • C, juste avant le trou, monte presque aussi vite, plafonne vers 190 °C, puis gagne encore une trentaine de degrés quand la source s’allume ;
  • B, sur le bord du haut au-dessus de la tranche chauffée, monte une première fois avec l’alésage, puis une seconde quand la source s’allume, entre 4 500 s et 7 500 s ;
  • A, le coin bas gauche, descend d’abord sous 0 °C — jusqu’à −30,68 °C vers 900 s : le film convectif à −80 °C le refroidit avant que la chaleur de l’alésage n’arrive. Il remonte ensuite ; le flux sortant de la face gauche, qui s’installe à partir de 4 500 s, le fait redescendre de quelques degrés, avant que la source ne l’emporte.

Au dernier instant, A vaut 40,25 °C, B 228,69 °C, C 222,98 °C et D 249,99 °C.

Le champ au fil du temps

    # FR — Les champs de température, un par instant, rangés dans une
    #      évolution : dans la fenêtre, un curseur passe d'un instant à
    #      l'autre. L'échelle va du fluide (−80 °C) à l'alésage (250 °C).
    # EN — The temperature fields, one per instant, stored in an evolution:
    #      in the window, a slider moves from one instant to the next. The
    #      scale runs from the fluid (−80 °C) to the bore (250 °C).
    champs = []
    for resultat in resultats:
        champs.append((resultat["time"], resultat["temperature"]))
    evolution = pc.Evolution(champs)

    for pas in PAS_TRACES:
        fichier = f"thermique-transitoire-t{instants[pas]:.0f}.svg"
        evolution.plot(
            mesh=volume,
            view=VUE,
            component="T",
            vmin=T_EXT,
            vmax=T_IMPOSEE,
            frame=pas,
            title=f"Température (°C) à t = {instants[pas]:.0f} s",
            save=os.path.join(OUT, fichier) if OUT else None,
        )

Une évolution peut tabuler des champs. Les 101 champs de température, rangés avec leurs instants, forment une pc.Evolution de champs ; tracée dans une fenêtre, elle montre le maillage avec un curseur qui passe d’un instant à l’autre. Pour ce livre, frame en fixe trois, sur une même échelle : de la température du fluide, −80 °C, à celle de l’alésage, 250 °C.

À 1 500 s, l’alésage vient d’atteindre 250 °C, et la chaleur n’a gagné que la moitié droite de la pièce : le reste est encore proche de 0 °C, et le bas du côté gauche est passé en dessous (−23,36 °C au plus froid).

À 6 000 s, la chaleur de l’alésage a gagné toute la pièce (7,63 °C au plus froid), et la source, à moitié allumée, commence à réchauffer la tranche.

À 15 000 s, les sollicitations sont pleines depuis 7 500 s, et la pièce s’est presque établie : 40,25 °C au plus froid, contre 40,26 °C pour l’étape 2 de la page Régime stationnaire.

Le flux de chaleur

La température est un champ aux nœuds ; son gradient, un champ aux points de Gauss. La température a une valeur par nœud, et elle est continue d’un élément à l’autre : deux éléments voisins lisent les mêmes nœuds. Son gradient se calcule dans chaque élément, à partir des valeurs nodales et des fonctions de forme : il vit aux points de Gauss, comme les coefficients du champ matériau, et il n’a aucune raison d’être continu d’un élément à l’autre. Le flux \( \phi = -\lambda \operatorname{grad} T \) aussi.

    # FR — Au dernier instant : le gradient aux points de Gauss, puis ce que la
    #      loi de conduction en tire, k·grad T. Le flux de Fourier, qui va du
    #      chaud vers le froid, est son opposé.
    # EN — At the last instant: the gradient at the Gauss points, then what
    #      the conduction law makes of it, k·grad T. Fourier's flux, from hot
    #      to cold, is its opposite.
    dernier = resultats[-1]["temperature"]
    conductivite = pc.element_field.material_field(conduction, [("k", K_COND)])
    gradient = pc.element_field.strain(conduction, dernier, conductivite)
    repos = pc.element_field.initial_state(conduction, conductivite)
    flux = -pc.element_field.integrate_behavior(
        conduction, gradient, conductivite, repos
    )

    # FR — Deux composantes dans le plan de la pièce : selon X, puis selon Z.
    # EN — Two components in the part's plane: along X, then along Z.
    for composante, fichier in [
        ("flux_x", "thermique-flux-x.svg"),
        ("flux_z", "thermique-flux-z.svg"),
    ]:
        volume.plot(
            view=VUE,
            field=flux,
            component=composante,
            title=f"Flux de chaleur {composante} (W/m²)",
            save=os.path.join(OUT, fichier) if OUT else None,
        )

pyrucast.element_field.strain calcule ce que le modèle de conduction lit : le gradient, composantes « grad_T_x », « grad_T_y », « grad_T_z ». pyrucast.element_field.integrate_behavior passe ce gradient à la loi de conduction, qui rend \( \lambda \operatorname{grad} T \) sous les noms « flux_x », « flux_y », « flux_z » — le flux de la forme faible. Le flux de Fourier, qui va du chaud vers le froid, en est l’opposé : d’où le - devant. initial_state donne l’état de départ que toute loi reçoit ; celle de la conduction n’en a pas, mais l’appel est le même pour toutes.

Selon X, le flux est négatif à gauche de la tranche chauffée : la chaleur y part vers la face gauche, qui l’évacue. Il est positif entre la tranche et le trou : la source chauffe assez pour que la chaleur y aille vers la droite. Au plus fort, −84 300 W/m² et +20 200 W/m² aux points de Gauss.

Selon Z, le flux va surtout vers le bas (négatif) : le film convectif, sous la pièce, aspire la chaleur. Il est le plus fort sous le trou, où la chaleur de l’alésage descend vers la face basse : jusqu’à −93 900 W/m².

Le tracé colore chaque élément avec ses propres valeurs : à la frontière entre deux éléments, la couleur peut sauter, et c’est voulu — ce saut est la discontinuité du flux d’un élément à l’autre, qu’un lissage effacerait.

Script complet

Une fois les explications retirées, tout tient en un script — du maillage importé au flux du dernier instant :

"""Tutoriel débutant — 3. Régime transitoire. / Beginner tutorial — 3. Transient.

FR — Reprend la **chape percée** et les sollicitations de la page « Régime
stationnaire », mais sur une pièce à 0 °C au départ, et avec des
sollicitations qui s'établissent au fil du temps : l'alésage monte à 250 °C
pendant les 1 500 premières secondes, le flux de la face gauche et la source
s'installent entre 4 500 s et 7 500 s, la convection agit dès le début. Le
calcul pas à pas (`pyrucast.stepping`) suit la pièce sur 15 000 s, en 100 pas :

1. **les chargements dans le temps** — deux champs matériau, sollicitations
   éteintes puis pleines, et une évolution qui passe de l'un à l'autre ; une
   évolution de la température imposée ;
2. **le calcul pas à pas** — un dictionnaire qui décrit le calcul, puis
   `pyrucast.stepping.step_by_step` ;
3. **les résultats** — la température en quatre points au fil du temps, le
   champ à trois instants, puis le flux de chaleur au dernier instant.

EN — Picks the **pierced lug** and the loads of the steady-state page back up,
but on a part at 0 °C to start with, and with loads that build up over time:
the bore rises to 250 °C over the first 1,500 seconds, the left face's flux
and the source set in between 4,500 s and 7,500 s, convection acts from the
start. The step-by-step analysis (`pyrucast.stepping`) follows the part over
15,000 s, in 100 steps:

1. **loads in time** — two material fields, loads off then on, and an
   evolution going from one to the other; an evolution of the imposed
   temperature;
2. **the step-by-step analysis** — a dictionary describing it, then
   `pyrucast.stepping.step_by_step`;
3. **the results** — the temperature at four points over time, the field at
   three instants, then the heat flux at the last instant.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/thermique_transitoire.py

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

import os

import pyrucast as pc
from maillage import HEIGHT, HOLE_RADIUS, LENGTH, OUT, THICKNESS, structured_mesh
from thermique import (
    FLUX_IMPOSE,
    H_CONV,
    K_COND,
    SOURCE_VOLUMIQUE,
    SOURCE_X_MAX,
    SOURCE_X_MIN,
    T_EXT,
    T_IMPOSEE,
    TOL,
    VUE,
)

# ── Données physiques / Physical data ──────────────────────────────────────
# FR — Les données du stationnaire sont importées de tutoriel/thermique.py ;
#      le transitoire ajoute la capacité de l'acier et le temps.
# EN — The steady-state data come from tutoriel/thermique.py; the transient
#      adds the steel's capacity and the time.
RHO = 7800.0  # kg/m³
CP = 420.0  # J/kg/K
T_INITIALE = 0.0  # °C, toute la pièce au départ / the whole part at start

T_FINAL = 15000.0  # s
N_PAS = 100  # pas de temps / time steps

# FR — Les rampes : l'alésage atteint 250 °C à 0,1 t_f ; le flux de la face
#      gauche et la source s'installent de 0,3 t_f à 0,5 t_f.
# EN — The ramps: the bore reaches 250 °C at 0.1 t_f; the left face's flux
#      and the source set in from 0.3 t_f to 0.5 t_f.
T_ALESAGE_CHAUD = 0.1 * T_FINAL  # s
T_DEBUT_CHARGES = 0.3 * T_FINAL  # s
T_PLEINES_CHARGES = 0.5 * T_FINAL  # s

# FR — Les quatre points suivis, à mi-épaisseur.
# EN — The four points followed, at mid-thickness.
POINT_A = [0.0, THICKNESS / 2.0, 0.0]  # coin bas gauche / lower left corner
POINT_B = [0.45 * LENGTH, THICKNESS / 2.0, HEIGHT]  # bord du haut / top edge
POINT_C = [
    0.8 * LENGTH,
    THICKNESS / 2.0,
    HEIGHT / 2.0,
]  # avant le trou / before the hole
POINT_D = [LENGTH + HEIGHT / 2.0, THICKNESS / 2.0, HEIGHT / 2.0]  # la pointe / the tip

# FR — Les instants tracés, par leur rang : 1 500 s, 6 000 s, 15 000 s.
# EN — The instants plotted, by rank: 1,500 s, 6,000 s, 15,000 s.
PAS_TRACES = [10, 40, 100]


def main() -> None:
    # FR — Le maillage de tutoriel/maillage.py, tel quel, et sa peau.
    # EN — tutoriel/maillage.py's mesh, as is, and its skin.
    _, volume = structured_mesh(plot=False)
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))

    # FR — Les régions de la page « Régime stationnaire », redécoupées à
    #      l'identique : l'alésage, la face gauche, la face basse, la tranche.
    # EN — The regions of the steady-state page, cut out again the same way:
    #      the bore, the left face, the bottom face, the slice.
    bas_axe = [LENGTH, -THICKNESS, HEIGHT / 2.0]
    haut_axe = [LENGTH, 2.0 * THICKNESS, HEIGHT / 2.0]
    alesage = pc.mesh.consolidate(
        pc.mesh.points_on_cylinder(volume, bas_axe, haut_axe, HOLE_RADIUS)
    )

    noeuds_gauche = pc.mesh.points_on_plane(peau, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    face_gauche = pc.mesh.elements_on(peau, noeuds_gauche, strict=True)

    z_peau = pc.node_field.positions(peau, ["Z"])
    noeuds_bas = pc.mesh.select(z_peau, ge=-TOL, le=TOL)
    face_basse = pc.mesh.elements_on(peau, noeuds_bas, strict=True)

    x_volume = pc.node_field.positions(volume, ["X"])
    noeuds_source = pc.mesh.select(x_volume, ge=SOURCE_X_MIN, le=SOURCE_X_MAX)
    zone_source = pc.mesh.consolidate(
        pc.mesh.elements_on(volume, noeuds_source, strict=True)
    )

    # FR — Le modèle complet d'un tenant : conduction, convection, blocage de
    #      l'alésage, puis les deux termes de flux.
    # EN — The whole model at once: conduction, convection, the bore's
    #      constraint, then the two flux terms.
    fes = pc.FiniteElementSpace(volume)
    conduction = pc.model.heat_conduction(fes)
    basse_fes = pc.FiniteElementSpace(face_basse)
    modele = conduction | pc.model.boundary_transfer(basse_fes, conduction, ["T"])
    modele = modele | pc.model.dirichlet(alesage, conduction, "T")

    flux_gauche = pc.model.flux(pc.FiniteElementSpace(face_gauche), modele, "q")
    source = pc.model.flux(pc.FiniteElementSpace(zone_source), modele, "q")

    # FR — Deux champs matériau construits de la même façon : les
    #      sollicitations éteintes (phi_q = 0), puis pleines. La capacité
    #      « rho » × « cp » est ce que le transitoire ajoute.
    # EN — Two material fields built the same way: the loads off
    #      (phi_q = 0), then on. The capacity "rho" × "cp" is what the
    #      transient adds.
    eteint = (
        pc.element_field.material_field(
            modele,
            [
                ("k", K_COND),
                ("h_T", H_CONV),
                ("a_ext_T", T_EXT),
                ("rho", RHO),
                ("cp", CP),
            ],
        )
        | pc.element_field.material_field(flux_gauche, [("phi_q", 0.0)])
        | pc.element_field.material_field(source, [("phi_q", 0.0)])
    )
    plein = (
        pc.element_field.material_field(
            modele,
            [
                ("k", K_COND),
                ("h_T", H_CONV),
                ("a_ext_T", T_EXT),
                ("rho", RHO),
                ("cp", CP),
            ],
        )
        | pc.element_field.material_field(flux_gauche, [("phi_q", FLUX_IMPOSE)])
        | pc.element_field.material_field(source, [("phi_q", SOURCE_VOLUMIQUE)])
    )

    # FR — L'évolution reste éteinte jusqu'à 0,3 t_f, puis passe à pleine à
    #      0,5 t_f ; entre deux instants, elle interpole.
    # EN — The evolution stays off until 0.3 t_f, then turns on by 0.5 t_f;
    #      between two instants, it interpolates.
    materiaux = pc.Evolution(
        [
            (0.0, eteint),
            (T_DEBUT_CHARGES, eteint),
            (T_PLEINES_CHARGES, plein),
            (T_FINAL, plein),
        ]
    )

    # FR — La température imposée, posée sur les multiplicateurs comme au
    #      stationnaire : 0 °C au départ, 250 °C dès 0,1 t_f.
    # EN — The imposed temperature, set on the multipliers as in the steady
    #      state: 0 °C at the start, 250 °C from 0.1 t_f on.
    froide = pc.NodeField(modele.multiplier_mesh(), ["imposed"]) + T_INITIALE
    chaude = pc.NodeField(modele.multiplier_mesh(), ["imposed"]) + T_IMPOSEE
    chargement = pc.Evolution(
        [(0.0, froide), (T_ALESAGE_CHAUD, chaude), (T_FINAL, chaude)]
    )

    # FR — Les instants calculés : 0, 150, 300… 15 000 s.
    # EN — The instants computed: 0, 150, 300… 15,000 s.
    instants = []
    for i in range(N_PAS + 1):
        instants.append(i * T_FINAL / N_PAS)

    # FR — Le calcul se décrit par un dictionnaire, puis se lance.
    # EN — The analysis is described by a dictionary, then run.
    data = {
        "model": modele | flux_gauche | source,
        "times": instants,
        "materials": materiaux,
        "loads": chargement,
        "initial": {"temperature": pc.NodeField(volume, ["T"]) + T_INITIALE},
    }
    pc.stepping.step_by_step(data)
    resultats = data["results"]

    # FR — Les nœuds les plus proches des quatre points suivis.
    # EN — The nodes closest to the four points followed.
    a = volume.nearest_node(POINT_A)
    b = volume.nearest_node(POINT_B)
    c = volume.nearest_node(POINT_C)
    d = volume.nearest_node(POINT_D)

    # FR — Une liste de couples (temps, température) par point, remplie
    #      instant après instant.
    # EN — One list of (time, temperature) pairs per point, filled instant
    #      after instant.
    courbe_a, courbe_b, courbe_c, courbe_d = [], [], [], []
    for resultat in resultats:
        t, temperature = resultat["time"], resultat["temperature"]
        courbe_a.append((t, temperature.value(a, "T")))
        courbe_b.append((t, temperature.value(b, "T")))
        courbe_c.append((t, temperature.value(c, "T")))
        courbe_d.append((t, temperature.value(d, "T")))

    # FR — Quatre courbes réunies par `|` : une ligne par courbe sur la figure.
    # EN — Four curves gathered by `|`: one line per curve on the figure.
    courbes = (
        pc.SubEvolution(courbe_a)
        | pc.SubEvolution(courbe_b)
        | pc.SubEvolution(courbe_c)
        | pc.SubEvolution(courbe_d)
    )
    courbes.plot(
        x_label="temps (s)",
        y_label="T (°C)",
        title="Température en A, B, C, D",
        save=os.path.join(OUT, "thermique-transitoire-courbes.svg") if OUT else None,
    )

    # FR — Les champs de température, un par instant, rangés dans une
    #      évolution : dans la fenêtre, un curseur passe d'un instant à
    #      l'autre. L'échelle va du fluide (−80 °C) à l'alésage (250 °C).
    # EN — The temperature fields, one per instant, stored in an evolution:
    #      in the window, a slider moves from one instant to the next. The
    #      scale runs from the fluid (−80 °C) to the bore (250 °C).
    champs = []
    for resultat in resultats:
        champs.append((resultat["time"], resultat["temperature"]))
    evolution = pc.Evolution(champs)

    for pas in PAS_TRACES:
        fichier = f"thermique-transitoire-t{instants[pas]:.0f}.svg"
        evolution.plot(
            mesh=volume,
            view=VUE,
            component="T",
            vmin=T_EXT,
            vmax=T_IMPOSEE,
            frame=pas,
            title=f"Température (°C) à t = {instants[pas]:.0f} s",
            save=os.path.join(OUT, fichier) if OUT else None,
        )

    # FR — Au dernier instant : le gradient aux points de Gauss, puis ce que la
    #      loi de conduction en tire, k·grad T. Le flux de Fourier, qui va du
    #      chaud vers le froid, est son opposé.
    # EN — At the last instant: the gradient at the Gauss points, then what
    #      the conduction law makes of it, k·grad T. Fourier's flux, from hot
    #      to cold, is its opposite.
    dernier = resultats[-1]["temperature"]
    conductivite = pc.element_field.material_field(conduction, [("k", K_COND)])
    gradient = pc.element_field.strain(conduction, dernier, conductivite)
    repos = pc.element_field.initial_state(conduction, conductivite)
    flux = -pc.element_field.integrate_behavior(
        conduction, gradient, conductivite, repos
    )

    # FR — Deux composantes dans le plan de la pièce : selon X, puis selon Z.
    # EN — Two components in the part's plane: along X, then along Z.
    for composante, fichier in [
        ("flux_x", "thermique-flux-x.svg"),
        ("flux_z", "thermique-flux-z.svg"),
    ]:
        volume.plot(
            view=VUE,
            field=flux,
            component=composante,
            title=f"Flux de chaleur {composante} (W/m²)",
            save=os.path.join(OUT, fichier) if OUT else None,
        )

    print(f"volume          : {volume.cell_count} HEX20")
    print(f"instants        : {len(resultats)}, de 0 à {T_FINAL:.0f} s")
    iterations = []
    for resultat in resultats:
        iterations.append(resultat["iterations"])
    print(f"itérations      : {max(iterations)} au plus par pas")
    for pas in PAS_TRACES:
        temperature = resultats[pas]["temperature"]
        print(
            f"t = {instants[pas]:>6.0f} s : T min = {temperature.min('T'):.2f} °C, "
            f"T max = {temperature.max('T'):.2f} °C"
        )
    t_froid, a_froid = courbe_a[0]
    for t, valeur in courbe_a:
        if valeur < a_froid:
            t_froid, a_froid = t, valeur
    print(f"A au plus froid : {a_froid:.2f} °C à {t_froid:.0f} s")
    print(
        f"points au dernier instant : A {dernier.value(a, 'T'):.2f}, "
        f"B {dernier.value(b, 'T'):.2f}, C {dernier.value(c, 'T'):.2f}, "
        f"D {dernier.value(d, 'T'):.2f} °C"
    )
    print(
        f"flux au dernier instant : flux_x de {flux.min('flux_x'):.0f} à "
        f"{flux.max('flux_x'):.0f} W/m², flux_z de {flux.min('flux_z'):.0f} à "
        f"{flux.max('flux_z'):.0f} W/m²"
    )


if __name__ == "__main__":
    main()

Suite : Thermique non linéaire.

Thermique non linéaire

Reprend le calcul de la page Régime transitoire — la chape percée à 0 °C, les mêmes sollicitations, les mêmes rampes — et ajoute un échange par rayonnement : la grande face arrière de la pièce rayonne vers un environnement lointain à −140 °C. Le calcul est mené deux fois, l’un après l’autre, sans puis avec le rayonnement, pour lire ce qu’il change en quatre points de la pièce. On parcourt enfin le champ dans le temps, d’un seul tracé, et on sauvegarde le calcul dans un fichier.

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

L’équation résolue

Sur la face rayonnante \( \partial V_r \), la pièce perd par rayonnement le flux

\[ \phi_r = \varepsilon \sigma\,(T^4 - T_\infty^4) \]

où \( \varepsilon \) est l’émissivité de la face, \( T_\infty \) la température de l’environnement et \( \sigma \) la constante de Stefan-Boltzmann. Ce flux entre dans le bilan comme la convection, mais il dépend de \( T^4 \) : il ne se range ni dans une matrice constante ni dans un second membre constant. Le système de la page Thermique linéaire devient

\[ [C]\{\dot{T}\} + [K]\{T\} + \{R(T)\} = \{P(t)\}, \qquad \{R(T)\} = \int_{\partial V_r} [N]^T\, \varepsilon \sigma\, (T^4 - T_\infty^4) \, dS \]

Un terme non linéaire, c’est un résidu qu’on annule par itérations. À chaque pas, avec \( \theta = 1 \) comme au transitoire, le calcul pas à pas cherche la température \( \{T\} \) qui annule

\[ \{r(T)\} = \{P(t_{n+1})\} - [K]\{T\} - \{R(T)\} - \frac{[C]}{\Delta t}\big(\{T\} - \{T_n\}\big) \]

en partant de \( \{T_n\} \) et en corrigeant pas à pas :

\[ \Big( \frac{[C]}{\Delta t} + [K] + [K_r(T_n)] \Big)\, \{\delta T\} = \{r(T)\}, \qquad [K_r(T_n)] = \int_{\partial V_r} 4 \varepsilon \sigma\, T_n^3\, [N]^T [N] \, dS \]

\( [K_r] \) est la tangente du rayonnement — la dérivée de \( \{R\} \) —, calculée une fois au début du pas, à \( T_n \). Le résidu, lui, est recalculé exactement à chaque itération, avec \( T^4 \). Le calcul s’arrête quand la correction tombe sous \( 10^{-4} \) fois la température. La température imposée garde ses multiplicateurs, comme au transitoire. La page Rayonnement détaille ce traitement — le flux évalué aux points de Gauss, la tangente exacte — et une autre méthode, qui traite le rayonnement comme une convection.

Chaque terme nouveau correspond à un objet du script :

Termepyrucast
\( \{R(T)\} \), \( [K_r] \)pc.model.radiation(fes, modele) sur la face rayonnante
\( \varepsilon \), \( T_\infty \)les coefficients « emis » et « T_inf » du champ matériau
\( \sigma \)la constante SI, \( 5{,}670374419 \cdot 10^{-8} \) W/m²/K⁴, par défaut
les itérationspc.stepping.step_by_step, le même appel qu’au transitoire

Les données du calcul

# FR — Le rayonnement : l'émissivité de la face, la température lointaine.
# EN — Radiation: the face's emissivity, the distant temperature.
EMISSIVITE = 0.9
T_INFINI = -140.0  # °C

# FR — Une puissance quatrième veut une température absolue : toutes les
#      températures du calcul sont décalées de 273,15 K.
# EN — A fourth power wants an absolute temperature: every temperature of the
#      analysis is shifted by 273.15 K.
ZERO_ABSOLU = 273.15  # K

Les données du stationnaire et du transitoire sont importées de leurs scripts ; le rayonnement ajoute son émissivité, 0,9, et la température lointaine, −140 °C.

Une puissance quatrième ne déplace pas son origine. La conduction et la convection ne voient que des écarts de température : on peut les écrire en degrés Celsius ou en kelvins, c’est le même calcul décalé de 273,15. Le rayonnement, lui, lit \( T^4 \), et \( (T + 273{,}15)^4 \) n’est pas \( T^4 \) plus une constante : pc.model.radiation attend une température absolue, en kelvins. Le script décale donc toutes ses températures de ZERO_ABSOLU — la température imposée, celle du fluide, celle de l’environnement, l’état initial — et retranche ce décalage pour tracer.

On reprend la pièce

    # FR — Le maillage de tutoriel/maillage.py, tel quel, et sa peau.
    # EN — tutoriel/maillage.py's mesh, as is, and its skin.
    _, volume = structured_mesh(plot=False)
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))
    # FR — Les régions de la page « Régime stationnaire », redécoupées à
    #      l'identique : l'alésage, la face gauche, la face basse, la tranche.
    # EN — The regions of the steady-state page, cut out again the same way:
    #      the bore, the left face, the bottom face, the slice.
    bas_axe = [LENGTH, -THICKNESS, HEIGHT / 2.0]
    haut_axe = [LENGTH, 2.0 * THICKNESS, HEIGHT / 2.0]
    alesage = pc.mesh.consolidate(
        pc.mesh.points_on_cylinder(volume, bas_axe, haut_axe, HOLE_RADIUS)
    )

    noeuds_gauche = pc.mesh.points_on_plane(peau, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    face_gauche = pc.mesh.elements_on(peau, noeuds_gauche, strict=True)

    z_peau = pc.node_field.positions(peau, ["Z"])
    noeuds_bas = pc.mesh.select(z_peau, ge=-TOL, le=TOL)
    face_basse = pc.mesh.elements_on(peau, noeuds_bas, strict=True)

    x_volume = pc.node_field.positions(volume, ["X"])
    noeuds_source = pc.mesh.select(x_volume, ge=SOURCE_X_MIN, le=SOURCE_X_MAX)
    zone_source = pc.mesh.consolidate(
        pc.mesh.elements_on(volume, noeuds_source, strict=True)
    )

Le maillage et les régions sont ceux du transitoire, redécoupés à l’identique.

La face rayonnante

    # FR — La face rayonnante : la grande face arrière, y = épaisseur, repérée
    #      par coordonnée comme la face basse.
    # EN — The radiating face: the large back face, y = thickness, located by
    #      coordinate like the bottom face.
    y_peau = pc.node_field.positions(peau, ["Y"])
    noeuds_arriere = pc.mesh.select(y_peau, ge=THICKNESS - TOL, le=THICKNESS + TOL)
    face_rayonnante = pc.mesh.elements_on(peau, noeuds_arriere, strict=True)

    face_rayonnante.unit().face_color = ROSE
    show(
        peau | face_rayonnante,
        "Face rayonnante",
        "thermique-cl-rayonnement.svg",
        wireframe=True,
    )

La face arrière se repère comme la face basse, par coordonnée : les nœuds de la peau où \( y \) vaut l’épaisseur, dans une bande de TOL, puis les QUA8 qu’ils portent entièrement — 320, toute la face.

Le modèle et son matériau

    # FR — Le modèle du transitoire, plus le terme de rayonnement sur la face
    #      arrière, posé contre la conduction comme la convection.
    # EN — The transient's model, plus the radiation term on the back face,
    #      set against the conduction like the convection.
    fes = pc.FiniteElementSpace(volume)
    conduction = pc.model.heat_conduction(fes)
    basse_fes = pc.FiniteElementSpace(face_basse)
    modele = conduction | pc.model.boundary_transfer(basse_fes, conduction, ["T"])
    modele = modele | pc.model.dirichlet(alesage, conduction, "T")

    flux_gauche = pc.model.flux(pc.FiniteElementSpace(face_gauche), modele, "q")
    source = pc.model.flux(pc.FiniteElementSpace(zone_source), modele, "q")
    rayonnement = pc.model.radiation(pc.FiniteElementSpace(face_rayonnante), modele)

Le rayonnement se pose comme la convection. pc.model.radiation prend l’espace éléments finis de la face et la conduction qu’il refroidit ; il partage ses degrés de liberté « T » et « q ». Le modèle du transitoire reste tel quel : le rayonnement n’est qu’un terme de plus, réuni par | au moment du calcul.

    # FR — Les deux champs matériau du transitoire, en kelvins, plus les
    #      coefficients du rayonnement : l'émissivité « emis » et la
    #      température lointaine « T_inf ».
    # EN — The transient's two material fields, in kelvins, plus the radiation
    #      coefficients: the emissivity "emis" and the distant temperature
    #      "T_inf".
    eteint = (
        pc.element_field.material_field(
            modele,
            [
                ("k", K_COND),
                ("h_T", H_CONV),
                ("a_ext_T", T_EXT + ZERO_ABSOLU),
                ("rho", RHO),
                ("cp", CP),
            ],
        )
        | pc.element_field.material_field(flux_gauche, [("phi_q", 0.0)])
        | pc.element_field.material_field(source, [("phi_q", 0.0)])
        | pc.element_field.material_field(
            rayonnement, [("emis", EMISSIVITE), ("T_inf", T_INFINI + ZERO_ABSOLU)]
        )
    )
    plein = (
        pc.element_field.material_field(
            modele,
            [
                ("k", K_COND),
                ("h_T", H_CONV),
                ("a_ext_T", T_EXT + ZERO_ABSOLU),
                ("rho", RHO),
                ("cp", CP),
            ],
        )
        | pc.element_field.material_field(flux_gauche, [("phi_q", FLUX_IMPOSE)])
        | pc.element_field.material_field(source, [("phi_q", SOURCE_VOLUMIQUE)])
        | pc.element_field.material_field(
            rayonnement, [("emis", EMISSIVITE), ("T_inf", T_INFINI + ZERO_ABSOLU)]
        )
    )
    materiaux = pc.Evolution(
        [
            (0.0, eteint),
            (T_DEBUT_CHARGES, eteint),
            (T_PLEINES_CHARGES, plein),
            (T_FINAL, plein),
        ]
    )

Les deux champs matériau du transitoire, en kelvins, reçoivent un quatrième morceau : celui du rayonnement, avec « emis » et « T_inf ». Il ne varie pas dans le temps, mais il fait partie des deux champs, si bien que l’évolution interpole comme avant.

    # FR — La température imposée, elle aussi en kelvins.
    # EN — The imposed temperature, in kelvins as well.
    froide = pc.NodeField(modele.multiplier_mesh(), ["imposed"]) + (
        T_INITIALE + ZERO_ABSOLU
    )
    chaude = pc.NodeField(modele.multiplier_mesh(), ["imposed"]) + (
        T_IMPOSEE + ZERO_ABSOLU
    )
    chargement = pc.Evolution(
        [(0.0, froide), (T_ALESAGE_CHAUD, chaude), (T_FINAL, chaude)]
    )

Deux calculs, sans puis avec rayonnement

    instants = []
    for i in range(N_PAS + 1):
        instants.append(i * T_FINAL / N_PAS)
    depart = pc.NodeField(volume, ["T"]) + (T_INITIALE + ZERO_ABSOLU)

    # FR — Deux calculs décrits de la même façon : seul le modèle change. Le
    #      champ matériau porte les coefficients du rayonnement dans les deux ;
    #      sans le terme, personne ne les lit.
    # EN — Two analyses described the same way: only the model changes. The
    #      material field carries the radiation coefficients in both; without
    #      the term, nobody reads them.
    sans = {
        "model": modele | flux_gauche | source,
        "times": instants,
        "materials": materiaux,
        "loads": chargement,
        "initial": {"temperature": depart},
    }
    avec = {
        "model": modele | flux_gauche | source | rayonnement,
        "times": instants,
        "materials": materiaux,
        "loads": chargement,
        "initial": {"temperature": depart},
    }
    pc.stepping.step_by_step(sans)
    pc.stepping.step_by_step(avec)

Les deux dictionnaires ne diffèrent que par leur modèle. Le champ matériau porte les coefficients du rayonnement dans les deux : sans le terme rayonnement, aucun modèle ne les lit, et le premier calcul est exactement le transitoire de la page précédente, en kelvins.

Le non linéaire ne coûte ici presque rien de plus. Le calcul avec rayonnement prend, comme l’autre, 2 itérations au plus par pas (175 en tout, contre 179 sans) : la tangente calculée au début du pas suit d’assez près \( T^3 \) pour qu’une correction suffise à la précision demandée.

Les résultats

    # FR — Les quatre points suivis, sans puis avec rayonnement, ramenés en °C.
    # EN — The four points followed, without then with radiation, back in °C.
    a = volume.nearest_node(POINT_A)
    b = volume.nearest_node(POINT_B)
    c = volume.nearest_node(POINT_C)
    d = volume.nearest_node(POINT_D)

    sans_a, sans_b, sans_c, sans_d = [], [], [], []
    avec_a, avec_b, avec_c, avec_d = [], [], [], []
    for r_sans, r_avec in zip(sans["results"], avec["results"]):
        t = r_sans["time"]
        sans_a.append((t, r_sans["temperature"].value(a, "T") - ZERO_ABSOLU))
        sans_b.append((t, r_sans["temperature"].value(b, "T") - ZERO_ABSOLU))
        sans_c.append((t, r_sans["temperature"].value(c, "T") - ZERO_ABSOLU))
        sans_d.append((t, r_sans["temperature"].value(d, "T") - ZERO_ABSOLU))
        avec_a.append((t, r_avec["temperature"].value(a, "T") - ZERO_ABSOLU))
        avec_b.append((t, r_avec["temperature"].value(b, "T") - ZERO_ABSOLU))
        avec_c.append((t, r_avec["temperature"].value(c, "T") - ZERO_ABSOLU))
        avec_d.append((t, r_avec["temperature"].value(d, "T") - ZERO_ABSOLU))

    # FR — Huit courbes sur une figure : A, B, C, D sans rayonnement, puis
    #      les mêmes avec.
    # EN — Eight curves on one figure: A, B, C, D without radiation, then the
    #      same with it.
    courbes = (
        pc.SubEvolution(sans_a)
        | pc.SubEvolution(sans_b)
        | pc.SubEvolution(sans_c)
        | pc.SubEvolution(sans_d)
        | pc.SubEvolution(avec_a)
        | pc.SubEvolution(avec_b)
        | pc.SubEvolution(avec_c)
        | pc.SubEvolution(avec_d)
    )
    courbes.plot(
        x_label="temps (s)",
        y_label="T (°C)",
        title="Température en A, B, C, D, sans puis avec rayonnement",
        save=os.path.join(OUT, "thermique-non-lineaire-courbes.svg") if OUT else None,
    )

La légende numérote les courbes dans l’ordre de l’union : zones 0 à 3 pour A, B, C, D sans rayonnement, zones 4 à 7 pour les mêmes points avec.

Le rayonnement refroidit toute la pièce, d’autant plus que le point est loin de l’alésage qui le tient. Au dernier instant :

pointsans rayonnementavec rayonnementécart
A, coin bas gauche40,25 °C21,73 °C−18,52 °C
B, bord du haut228,69 °C205,52 °C−23,17 °C
C, avant le trou222,98 °C214,07 °C−8,91 °C
D, la pointe249,99 °C249,57 °C−0,42 °C

D, à 1,5 cm de l’alésage tenu à 250 °C, ne bouge presque pas — ses deux courbes, zones 3 et 7, se recouvrent sur la figure ; B, loin de lui et au-dessus de la tranche chauffée, perd le plus : la face arrière, à plus de 200 °C, y rayonne vers −140 °C. Au tout début, les courbes se confondent : le flux rayonné croît comme \( T^4 \) en kelvins, et une face à 0 °C rayonne près de dix fois moins qu’à 200 °C.

    # FR — Le champ du dernier instant avec rayonnement, ramené en °C.
    # EN — The last instant's field with radiation, back in °C.
    final = avec["results"][-1]["temperature"] - ZERO_ABSOLU
    show_nodefield(
        volume,
        final,
        "Avec rayonnement — température (°C) à 15 000 s",
        "thermique-non-lineaire-final.svg",
    )

Au dernier instant, le point le plus froid est à 21,67 °C au lieu de 40,25 °C sans rayonnement ; l’alésage reste à 250 °C, tenu par son blocage.

Parcourir le calcul dans le temps

    # FR — Les 101 champs avec rayonnement, ramenés en °C, rangés dans une
    #      évolution et tracés d'un seul appel : dans la fenêtre, un curseur
    #      parcourt les instants. L'échelle reste celle du transitoire, du
    #      fluide (−80 °C) à l'alésage (250 °C). `frame` choisit l'instant de
    #      l'image enregistrée : le pas 50, à 7 500 s.
    # EN — The 101 fields with radiation, back in °C, stored in an evolution
    #      and plotted in a single call: in the window, a slider runs through
    #      the instants. The scale stays the transient's, from the fluid
    #      (−80 °C) to the bore (250 °C). `frame` picks the saved image's
    #      instant: step 50, at 7,500 s.
    champs = []
    for resultat in avec["results"]:
        champs.append((resultat["time"], resultat["temperature"] - ZERO_ABSOLU))
    evolution = pc.Evolution(champs)
    evolution.plot(
        mesh=volume,
        view=VUE,
        component="T",
        vmin=T_EXT,
        vmax=T_IMPOSEE,
        x_label="temps (s)",
        title="Avec rayonnement — température (°C)",
        frame=N_PAS // 2,
        save=os.path.join(OUT, "thermique-non-lineaire-t7500.svg") if OUT else None,
    )

Pas de boucle de tracés : une évolution se parcourt. Plutôt que de tracer les 101 instants l’un après l’autre, on range les champs, ramenés en °C, dans une pc.Evolution de champs, et on la trace d’un seul appel. Dans la fenêtre, un curseur sous la pièce passe d’un instant à l’autre : on le fait glisser à la souris, ou on avance d’un pas avec les flèches ← et →. Au-dessus du curseur s’affichent le numéro de l’instant et le temps, sous le nom donné par x_label. La fenêtre s’ouvre sur le premier instant, la pièce entière à 0 °C. L’échelle, fixée par vmin et vmax du fluide à l’alésage comme au transitoire, ne change pas d’un instant à l’autre : les couleurs se comparent.

Une image de livre ne se parcourt pas : frame choisit l’instant qu’on enregistre, ici le pas 50, à 7 500 s. Sans save, la fenêtre les montre tous, et frame ne sert pas.

À 7 500 s, les sollicitations deviennent pleines, et toute la pièce est passée au-dessus de 0 °C : 4,01 °C au plus froid. Il reste la seconde moitié du calcul pour qu’elle s’établisse, à 21,67 °C au plus froid.

Sauvegarder le calcul

    # FR — Le volume et l'évolution des températures, écrits dans un fichier
    #      sous des clefs libres, puis relus : un dictionnaire à l'aller, un
    #      dictionnaire au retour. Un nœud du volume relu lit l'évolution
    #      relue, ici interpolée à 10 000 s, entre deux pas.
    # EN — The volume and the temperature evolution, written to a file under
    #      free keys, then read back: a dictionary on the way out, a
    #      dictionary on the way back. A node of the volume read back reads
    #      the evolution read back, interpolated here at 10,000 s, between
    #      two steps.
    chemin = os.path.join(tempfile.gettempdir(), "thermique.pyr")
    pc.save(chemin, {"volume": volume, "température (°C)": evolution})

    relu = pc.load(chemin)
    volume_relu = relu["volume"]
    evolution_relue = relu["température (°C)"]
    b_relu = volume_relu.nearest_node(POINT_B)
    t_b = evolution_relue.interpolate(10000.0).value(b_relu, "T")

Un fichier garde le calcul pour plus tard. pc.save écrit des objets sous des clefs libres — un accent, une unité entre parenthèses — et pc.load rend le même dictionnaire, avec des objets neufs. On ne donne que ce qui intéresse, le volume et l’évolution : ce dont ils ont besoin, les nœuds et les supports des champs, suit tout seul. Le fichier pèse ici 3,5 Mo, pour 640 HEX20 et 101 champs.

Le partage survit à la relecture. Les champs relus portent les nœuds du volume relu, pas des copies : un nœud cherché sur volume_relu lit directement l’évolution relue. Interpolée à 10 000 s, entre deux pas de calcul, elle donne 204,69 °C en B, entre 204,58 °C à 9 900 s et 204,74 °C à 10 050 s.

Le fichier va dans le dossier temporaire du système, pour ne rien laisser dans le dossier du tutoriel ; tout autre chemin convient. La page Sauvegarde et relecture dit ce que le fichier porte, et ce qu’il ne porte pas.

Script complet

Une fois les explications retirées, tout tient en un script — du maillage importé à la sauvegarde du calcul :

"""Tutoriel débutant — 4. Thermique non linéaire. / Beginner tutorial — 4. Nonlinear heat.

FR — Reprend le calcul de la page « Régime transitoire » — la chape percée à
0 °C, les mêmes sollicitations et les mêmes rampes — et ajoute un échange par
**rayonnement** : la grande face arrière de la pièce rayonne vers un
environnement lointain à −140 °C. Le rayonnement est en puissance quatrième de
la température : le problème n'est plus linéaire, et il se calcule en
**kelvins**. Le script mène deux calculs, l'un après l'autre :

1. **sans rayonnement**, en kelvins — il redonne le transitoire de la page
   précédente ;
2. **avec rayonnement** — même description, un terme de plus dans le modèle ;

puis compare les températures des quatre points suivis, parcourt le champ
dans le temps d'un seul tracé, et sauvegarde le calcul dans un fichier.

EN — Picks the transient page's analysis back up — the pierced lug at 0 °C,
the same loads and the same ramps — and adds a **radiation** exchange: the
part's large back face radiates towards a distant environment at −140 °C.
Radiation goes as the fourth power of the temperature: the problem is no
longer linear, and it is computed in **kelvins**. The script runs two
analyses, one after the other:

1. **without radiation**, in kelvins — it gives back the previous page's
   transient;
2. **with radiation** — the same description, one more term in the model;

then compares the temperatures of the four points followed, runs through
the field over time in a single plot, and saves the analysis to a file.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/thermique_non_lineaire.py

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

import os
import tempfile

import pyrucast as pc
from maillage import HEIGHT, HOLE_RADIUS, LENGTH, OUT, THICKNESS, show, structured_mesh
from thermique import (
    FLUX_IMPOSE,
    H_CONV,
    K_COND,
    SOURCE_VOLUMIQUE,
    SOURCE_X_MAX,
    SOURCE_X_MIN,
    T_EXT,
    T_IMPOSEE,
    TOL,
    VUE,
    show_nodefield,
)
from thermique_transitoire import (
    CP,
    N_PAS,
    POINT_A,
    POINT_B,
    POINT_C,
    POINT_D,
    RHO,
    T_ALESAGE_CHAUD,
    T_DEBUT_CHARGES,
    T_FINAL,
    T_INITIALE,
    T_PLEINES_CHARGES,
)

# FR — La couleur de la face rayonnante sur les figures.
# EN — The radiating face's colour on the figures.
ROSE = (230, 80, 160)

# ── Données physiques / Physical data ──────────────────────────────────────
# FR — Le rayonnement : l'émissivité de la face, la température lointaine.
# EN — Radiation: the face's emissivity, the distant temperature.
EMISSIVITE = 0.9
T_INFINI = -140.0  # °C

# FR — Une puissance quatrième veut une température absolue : toutes les
#      températures du calcul sont décalées de 273,15 K.
# EN — A fourth power wants an absolute temperature: every temperature of the
#      analysis is shifted by 273.15 K.
ZERO_ABSOLU = 273.15  # K


def main() -> None:
    # FR — Le maillage de tutoriel/maillage.py, tel quel, et sa peau.
    # EN — tutoriel/maillage.py's mesh, as is, and its skin.
    _, volume = structured_mesh(plot=False)
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))

    # FR — Les régions de la page « Régime stationnaire », redécoupées à
    #      l'identique : l'alésage, la face gauche, la face basse, la tranche.
    # EN — The regions of the steady-state page, cut out again the same way:
    #      the bore, the left face, the bottom face, the slice.
    bas_axe = [LENGTH, -THICKNESS, HEIGHT / 2.0]
    haut_axe = [LENGTH, 2.0 * THICKNESS, HEIGHT / 2.0]
    alesage = pc.mesh.consolidate(
        pc.mesh.points_on_cylinder(volume, bas_axe, haut_axe, HOLE_RADIUS)
    )

    noeuds_gauche = pc.mesh.points_on_plane(peau, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    face_gauche = pc.mesh.elements_on(peau, noeuds_gauche, strict=True)

    z_peau = pc.node_field.positions(peau, ["Z"])
    noeuds_bas = pc.mesh.select(z_peau, ge=-TOL, le=TOL)
    face_basse = pc.mesh.elements_on(peau, noeuds_bas, strict=True)

    x_volume = pc.node_field.positions(volume, ["X"])
    noeuds_source = pc.mesh.select(x_volume, ge=SOURCE_X_MIN, le=SOURCE_X_MAX)
    zone_source = pc.mesh.consolidate(
        pc.mesh.elements_on(volume, noeuds_source, strict=True)
    )

    # FR — La face rayonnante : la grande face arrière, y = épaisseur, repérée
    #      par coordonnée comme la face basse.
    # EN — The radiating face: the large back face, y = thickness, located by
    #      coordinate like the bottom face.
    y_peau = pc.node_field.positions(peau, ["Y"])
    noeuds_arriere = pc.mesh.select(y_peau, ge=THICKNESS - TOL, le=THICKNESS + TOL)
    face_rayonnante = pc.mesh.elements_on(peau, noeuds_arriere, strict=True)

    face_rayonnante.unit().face_color = ROSE
    show(
        peau | face_rayonnante,
        "Face rayonnante",
        "thermique-cl-rayonnement.svg",
        wireframe=True,
    )

    # FR — Le modèle du transitoire, plus le terme de rayonnement sur la face
    #      arrière, posé contre la conduction comme la convection.
    # EN — The transient's model, plus the radiation term on the back face,
    #      set against the conduction like the convection.
    fes = pc.FiniteElementSpace(volume)
    conduction = pc.model.heat_conduction(fes)
    basse_fes = pc.FiniteElementSpace(face_basse)
    modele = conduction | pc.model.boundary_transfer(basse_fes, conduction, ["T"])
    modele = modele | pc.model.dirichlet(alesage, conduction, "T")

    flux_gauche = pc.model.flux(pc.FiniteElementSpace(face_gauche), modele, "q")
    source = pc.model.flux(pc.FiniteElementSpace(zone_source), modele, "q")
    rayonnement = pc.model.radiation(pc.FiniteElementSpace(face_rayonnante), modele)

    # FR — Les deux champs matériau du transitoire, en kelvins, plus les
    #      coefficients du rayonnement : l'émissivité « emis » et la
    #      température lointaine « T_inf ».
    # EN — The transient's two material fields, in kelvins, plus the radiation
    #      coefficients: the emissivity "emis" and the distant temperature
    #      "T_inf".
    eteint = (
        pc.element_field.material_field(
            modele,
            [
                ("k", K_COND),
                ("h_T", H_CONV),
                ("a_ext_T", T_EXT + ZERO_ABSOLU),
                ("rho", RHO),
                ("cp", CP),
            ],
        )
        | pc.element_field.material_field(flux_gauche, [("phi_q", 0.0)])
        | pc.element_field.material_field(source, [("phi_q", 0.0)])
        | pc.element_field.material_field(
            rayonnement, [("emis", EMISSIVITE), ("T_inf", T_INFINI + ZERO_ABSOLU)]
        )
    )
    plein = (
        pc.element_field.material_field(
            modele,
            [
                ("k", K_COND),
                ("h_T", H_CONV),
                ("a_ext_T", T_EXT + ZERO_ABSOLU),
                ("rho", RHO),
                ("cp", CP),
            ],
        )
        | pc.element_field.material_field(flux_gauche, [("phi_q", FLUX_IMPOSE)])
        | pc.element_field.material_field(source, [("phi_q", SOURCE_VOLUMIQUE)])
        | pc.element_field.material_field(
            rayonnement, [("emis", EMISSIVITE), ("T_inf", T_INFINI + ZERO_ABSOLU)]
        )
    )
    materiaux = pc.Evolution(
        [
            (0.0, eteint),
            (T_DEBUT_CHARGES, eteint),
            (T_PLEINES_CHARGES, plein),
            (T_FINAL, plein),
        ]
    )

    # FR — La température imposée, elle aussi en kelvins.
    # EN — The imposed temperature, in kelvins as well.
    froide = pc.NodeField(modele.multiplier_mesh(), ["imposed"]) + (
        T_INITIALE + ZERO_ABSOLU
    )
    chaude = pc.NodeField(modele.multiplier_mesh(), ["imposed"]) + (
        T_IMPOSEE + ZERO_ABSOLU
    )
    chargement = pc.Evolution(
        [(0.0, froide), (T_ALESAGE_CHAUD, chaude), (T_FINAL, chaude)]
    )

    instants = []
    for i in range(N_PAS + 1):
        instants.append(i * T_FINAL / N_PAS)
    depart = pc.NodeField(volume, ["T"]) + (T_INITIALE + ZERO_ABSOLU)

    # FR — Deux calculs décrits de la même façon : seul le modèle change. Le
    #      champ matériau porte les coefficients du rayonnement dans les deux ;
    #      sans le terme, personne ne les lit.
    # EN — Two analyses described the same way: only the model changes. The
    #      material field carries the radiation coefficients in both; without
    #      the term, nobody reads them.
    sans = {
        "model": modele | flux_gauche | source,
        "times": instants,
        "materials": materiaux,
        "loads": chargement,
        "initial": {"temperature": depart},
    }
    avec = {
        "model": modele | flux_gauche | source | rayonnement,
        "times": instants,
        "materials": materiaux,
        "loads": chargement,
        "initial": {"temperature": depart},
    }
    pc.stepping.step_by_step(sans)
    pc.stepping.step_by_step(avec)

    # FR — Les quatre points suivis, sans puis avec rayonnement, ramenés en °C.
    # EN — The four points followed, without then with radiation, back in °C.
    a = volume.nearest_node(POINT_A)
    b = volume.nearest_node(POINT_B)
    c = volume.nearest_node(POINT_C)
    d = volume.nearest_node(POINT_D)

    sans_a, sans_b, sans_c, sans_d = [], [], [], []
    avec_a, avec_b, avec_c, avec_d = [], [], [], []
    for r_sans, r_avec in zip(sans["results"], avec["results"]):
        t = r_sans["time"]
        sans_a.append((t, r_sans["temperature"].value(a, "T") - ZERO_ABSOLU))
        sans_b.append((t, r_sans["temperature"].value(b, "T") - ZERO_ABSOLU))
        sans_c.append((t, r_sans["temperature"].value(c, "T") - ZERO_ABSOLU))
        sans_d.append((t, r_sans["temperature"].value(d, "T") - ZERO_ABSOLU))
        avec_a.append((t, r_avec["temperature"].value(a, "T") - ZERO_ABSOLU))
        avec_b.append((t, r_avec["temperature"].value(b, "T") - ZERO_ABSOLU))
        avec_c.append((t, r_avec["temperature"].value(c, "T") - ZERO_ABSOLU))
        avec_d.append((t, r_avec["temperature"].value(d, "T") - ZERO_ABSOLU))

    # FR — Huit courbes sur une figure : A, B, C, D sans rayonnement, puis
    #      les mêmes avec.
    # EN — Eight curves on one figure: A, B, C, D without radiation, then the
    #      same with it.
    courbes = (
        pc.SubEvolution(sans_a)
        | pc.SubEvolution(sans_b)
        | pc.SubEvolution(sans_c)
        | pc.SubEvolution(sans_d)
        | pc.SubEvolution(avec_a)
        | pc.SubEvolution(avec_b)
        | pc.SubEvolution(avec_c)
        | pc.SubEvolution(avec_d)
    )
    courbes.plot(
        x_label="temps (s)",
        y_label="T (°C)",
        title="Température en A, B, C, D, sans puis avec rayonnement",
        save=os.path.join(OUT, "thermique-non-lineaire-courbes.svg") if OUT else None,
    )

    # FR — Le champ du dernier instant avec rayonnement, ramené en °C.
    # EN — The last instant's field with radiation, back in °C.
    final = avec["results"][-1]["temperature"] - ZERO_ABSOLU
    show_nodefield(
        volume,
        final,
        "Avec rayonnement — température (°C) à 15 000 s",
        "thermique-non-lineaire-final.svg",
    )

    # FR — Les 101 champs avec rayonnement, ramenés en °C, rangés dans une
    #      évolution et tracés d'un seul appel : dans la fenêtre, un curseur
    #      parcourt les instants. L'échelle reste celle du transitoire, du
    #      fluide (−80 °C) à l'alésage (250 °C). `frame` choisit l'instant de
    #      l'image enregistrée : le pas 50, à 7 500 s.
    # EN — The 101 fields with radiation, back in °C, stored in an evolution
    #      and plotted in a single call: in the window, a slider runs through
    #      the instants. The scale stays the transient's, from the fluid
    #      (−80 °C) to the bore (250 °C). `frame` picks the saved image's
    #      instant: step 50, at 7,500 s.
    champs = []
    for resultat in avec["results"]:
        champs.append((resultat["time"], resultat["temperature"] - ZERO_ABSOLU))
    evolution = pc.Evolution(champs)
    evolution.plot(
        mesh=volume,
        view=VUE,
        component="T",
        vmin=T_EXT,
        vmax=T_IMPOSEE,
        x_label="temps (s)",
        title="Avec rayonnement — température (°C)",
        frame=N_PAS // 2,
        save=os.path.join(OUT, "thermique-non-lineaire-t7500.svg") if OUT else None,
    )

    # FR — Le volume et l'évolution des températures, écrits dans un fichier
    #      sous des clefs libres, puis relus : un dictionnaire à l'aller, un
    #      dictionnaire au retour. Un nœud du volume relu lit l'évolution
    #      relue, ici interpolée à 10 000 s, entre deux pas.
    # EN — The volume and the temperature evolution, written to a file under
    #      free keys, then read back: a dictionary on the way out, a
    #      dictionary on the way back. A node of the volume read back reads
    #      the evolution read back, interpolated here at 10,000 s, between
    #      two steps.
    chemin = os.path.join(tempfile.gettempdir(), "thermique.pyr")
    pc.save(chemin, {"volume": volume, "température (°C)": evolution})

    relu = pc.load(chemin)
    volume_relu = relu["volume"]
    evolution_relue = relu["température (°C)"]
    b_relu = volume_relu.nearest_node(POINT_B)
    t_b = evolution_relue.interpolate(10000.0).value(b_relu, "T")

    print(f"face rayonnante : {face_rayonnante.cell_count} QUA8")
    for nom, calcul in [("sans", sans), ("avec", avec)]:
        iterations = []
        for resultat in calcul["results"]:
            iterations.append(resultat["iterations"])
        dernier = calcul["results"][-1]["temperature"]
        print(
            f"{nom} rayonnement : {max(iterations)} itérations au plus par pas, "
            f"{sum(iterations)} en tout ; au dernier instant T min = "
            f"{dernier.min('T') - ZERO_ABSOLU:.2f} °C, T max = "
            f"{dernier.max('T') - ZERO_ABSOLU:.2f} °C"
        )
    print(
        f"points au dernier instant, sans : A {sans_a[-1][1]:.2f}, "
        f"B {sans_b[-1][1]:.2f}, C {sans_c[-1][1]:.2f}, D {sans_d[-1][1]:.2f} °C"
    )
    print(
        f"points au dernier instant, avec : A {avec_a[-1][1]:.2f}, "
        f"B {avec_b[-1][1]:.2f}, C {avec_c[-1][1]:.2f}, D {avec_d[-1][1]:.2f} °C"
    )
    milieu = evolution.frames()[N_PAS // 2]
    print(
        f"avec rayonnement à {instants[N_PAS // 2]:.0f} s : T min = "
        f"{milieu.min('T'):.2f} °C, T max = {milieu.max('T'):.2f} °C"
    )
    print(
        f"sauvegarde {chemin} : {os.path.getsize(chemin) / 1e6:.1f} Mo ; relu "
        f"{volume_relu.cell_count} HEX20, {len(evolution_relue.frames())} champs"
    )
    print(
        f"B relu à 10000 s : {t_b:.2f} °C, entre {avec_b[66][1]:.2f} °C à "
        f"{avec_b[66][0]:.0f} s et {avec_b[67][1]:.2f} °C à {avec_b[67][0]:.0f} s"
    )


if __name__ == "__main__":
    main()

Suite : Mécanique linéaire.

Mécanique linéaire

Les trois pages de ce chapitre chargent la chape percée de la page Maillage. La pièce est encastrée par sa face gauche, et une masse de 2 500 kg y est suspendue par l’alésage.

Cette page pose l’équation que les trois résolvent. Chacune la reprend ensuite dans sa forme à elle, jusqu’au système que pyrucast résout vraiment.

L’équilibre

En chaque point de la pièce, les efforts intérieurs équilibrent les efforts appliqués :

\[ \operatorname{div}(\sigma) + f = 0 \ \text{ dans } V \]

\( \sigma \)le tenseur des contraintes, en Pa : les efforts intérieurs par unité de surface
\( f \)les forces volumiques, en N/m³, comme le poids propre
\( u \)le déplacement de chaque point, en m

Sur le bord, deux sortes de conditions se partagent la surface. Sur \( \partial V_u \) le déplacement est imposé ; sur \( \partial V_t \) c’est l’effort appliqué par unité de surface qui est donné, avec \( n \) la normale sortante :

\[ u = u_{\text{imp}} \ \text{ sur } \partial V_u, \qquad \sigma \cdot n = t_{\text{imp}} \ \text{ sur } \partial V_t \]

Un encastrement est un déplacement imposé nul. Une pression \( p \) qui appuie sur le bord est un effort \( t_{\text{imp}} = -p\,n \) : il pousse contre la normale sortante, donc vers l’intérieur de la matière.

Petites déformations et loi de Hooke

Tant que les déplacements restent petits devant la pièce, la déformation se lit sur les dérivées du déplacement :

\[ \varepsilon = \tfrac{1}{2} \left( \operatorname{grad}(u) + \operatorname{grad}(u)^T \right) \]

Un acier qui reste élastique suit la loi de Hooke : la contrainte est proportionnelle à la déformation. La partie due à la température, \( \varepsilon_{\text{th}} \), ne crée aucune contrainte par elle-même ; seule la différence entre la déformation totale et la dilatation en crée :

\[ \sigma = [C] \left( \varepsilon - \varepsilon_{\text{th}} \right), \qquad \varepsilon_{\text{th}} = \alpha\,(T - T_{\text{ref}})\,I \]

\( [C] \)la matrice d’élasticité, construite sur le module d’Young \( E \) et le coefficient de Poisson \( \nu \)
\( \alpha \)le coefficient de dilatation, en 1/K
\( T_{\text{ref}} \)la température à laquelle la pièce n’est pas dilatée
\( I \)l’identité : la dilatation est la même dans toutes les directions

La page Élasticité n’a pas de dilatation : \( \varepsilon_{\text{th}} = 0 \) et \( \sigma = [C]\,\varepsilon \).

La forme discrétisée

Sur les éléments finis, le déplacement s’interpole à partir de ses valeurs aux nœuds, \( u(x) = [N(x)]\{U\} \), et la déformation aussi, \( \varepsilon(x) = [B(x)]\{U\} \). L’équilibre écrit en travaux virtuels devient un système linéaire :

\[ [K]\{U\} = \{F_v\} + \{F_s\} + \{F_{\text{th}}\} + \{F_{\text{reac}}\} \]

\[ [K] = \int_V [B]^T [C] [B] \, dV \qquad \text{la rigidité} \]

\[ \{F_v\} = \int_V [N]^T f \, dV \qquad \text{les forces volumiques} \]

\[ \{F_s\} = \int_{\partial V_t} [N]^T t_{\text{imp}} \, dS \qquad \text{les forces de surface} \]

\[ \{F_{\text{th}}\} = \int_V [B]^T [C]\,\varepsilon_{\text{th}} \, dV \qquad \text{la dilatation} \]

Le dernier terme, \( \{F_{\text{reac}}\} \), est l’effort que l’encastrement exerce sur la pièce pour la tenir : la réaction. Elle n’est pas connue d’avance, c’est le déplacement imposé qui l’est. pyrucast l’obtient par des multiplicateurs de Lagrange \( \{\lambda\} \), un par composante et par nœud bloqué. La relation \( [A]\{U\} = \{U_{\text{imp}}\} \), où chaque ligne de \( [A] \) lit une composante du déplacement d’un nœud bloqué, s’ajoute au système :

\[ \begin{bmatrix} K & A^T \\ A & 0 \end{bmatrix} \begin{Bmatrix} U \\ \lambda \end{Bmatrix} = \begin{Bmatrix} F \\ U_{\text{imp}} \end{Bmatrix} \]

Sur la première ligne, \( [K]\{U\} = \{F\} - [A]^T\{\lambda\} \) : la réaction est \( \{F_{\text{reac}}\} = -[A]^T\{\lambda\} \). Le multiplicateur \( \lambda \) est l’effort que la pièce exerce sur le blocage, la réaction son opposé.

Linéaire veut dire : rien ne dépend du déplacement. \( [K] \) se calcule et se factorise une fois pour toutes, et chaque nouveau chargement ne coûte qu’une résolution de plus. La page Mécanique non linéaire lève cette hypothèse.

La pièce et ses sollicitations

SollicitationOùValeur
encastrementla face gauche, \( x = 0 \)\( u = 0 \)
masse suspenduel’alésage2 500 kg, soit un poids \( mg \) de 24 525 N
dilatation (9.7.2 et 9.7.3)toute la piècela température finale du calcul avec rayonnement

Le matériau est un acier : \( E = 200 \) GPa, \( \nu = 0{,}3 \), et pour la dilatation \( \alpha = 10^{-5} \) /K, avec \( T_{\text{ref}} = 0 \) °C.

Le parcours

  1. Élasticité : la masse posée de trois façons, trois résolutions sur la même matrice, puis les réactions, la déformée, les contraintes et la contrainte de von Mises le long de deux lignes.
  2. Dilatation, matériau homogène : la température relue dans le fichier écrit par la page Thermique non linéaire, et la dilatation qu’elle produit.
  3. Dilatation, matériau hétérogène : le même calcul, avec un coefficient de dilatation qui varie le long de la pièce.

Puis Mécanique non linéaire ajoute la plasticité et le contact.

Suite : Élasticité.

Élasticité

La chape percée de la page Maillage est encastrée par sa face gauche, et une masse de 2 500 kg est suspendue à son alésage. Ce premier calcul de mécanique pose la masse de trois façons : une force en un point, une pression, une force surfacique verticale. Il compare ce qu’elles donnent, puis lit sur le dernier cas tout ce qu’un calcul élastique fournit : réactions, déformée, déformations, contraintes, contrainte de von Mises, et valeurs le long d’une ligne.

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

L’équation résolue

La page Mécanique linéaire a posé l’équilibre et sa forme discrétisée. Ici, pas de dilatation ni de force volumique : il reste la rigidité, les forces de surface et la réaction de l’encastrement,

\[ [K]\{U\} = \{F_s\} + \{F_{\text{reac}}\}, \qquad [K] = \int_V [B]^T [C] [B] \, dV, \qquad \{F_s\} = \int_{\partial V_t} [N]^T t_{\text{imp}} \, dS \]

L’encastrement bloque les trois composantes du déplacement sur la face \( x = 0 \). Chacune passe par ses multiplicateurs de Lagrange, et le système que pyrucast résout est

\[ \begin{bmatrix} K & A^T \\ A & 0 \end{bmatrix} \begin{Bmatrix} U \\ \lambda \end{Bmatrix} = \begin{Bmatrix} F_s \\ 0 \end{Bmatrix} \]

La réaction est \( -[A]^T\{\lambda\} \). Les trois façons de poser la masse ne changent que \( \{F_s\} \) :

CasEffort\( \{F_s\} \)
1. force ponctuelle\( -mg \) selon \( z \), au bas de l’alésageune seule ligne non nulle
2. pression\( p \) sur la moitié basse de l’alésage, contre la normale\( \int [N]^T (-p\,n) \, dS \)
3. force surfacique verticale\( p \) vers le bas, sur la même moitié\( \int [N]^T (0, 0, -p) \, dS \)

Pour les cas 2 et 3, \( p \) est le poids divisé par l’aire de la moitié basse de l’alésage, un demi-cylindre de rayon \( r \) et d’épaisseur \( e \) : \( p = mg / (\pi r e) \).

Les contraintes se lisent ensuite aux points de Gauss, là où le calcul les connaît : \( \varepsilon = [B]\{U\} \), \( \sigma = [C]\,\varepsilon \). Pour juger d’un état de contrainte d’un seul nombre, on calcule la contrainte équivalente de von Mises :

\[ \sigma_{\text{VM}} = \sqrt{ \tfrac{1}{2} \left[ (\sigma_{xx} - \sigma_{yy})^2 + (\sigma_{yy} - \sigma_{zz})^2 + (\sigma_{zz} - \sigma_{xx})^2 \right] + 3 \left( \sigma_{xy}^2 + \sigma_{yz}^2 + \sigma_{xz}^2 \right) } \]

Un acier commence à se déformer de façon permanente quand elle atteint sa limite d’élasticité. Elle vaut 0 pour une pression égale dans toutes les directions, qui ne déforme pas un acier de façon permanente.

Les données du calcul

E, NU = 200e9, 0.3  # Pa, — : un acier / a steel
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N

# FR — Le poids réparti sur la moitié basse de l'alésage, d'aire π·r·e.
# EN — The weight spread over the lower half of the bore, of area π·r·e.
PRESSION = POIDS / (math.pi * HOLE_RADIUS * THICKNESS)  # Pa

# FR — La déformée est tracée 50 fois plus grande que nature.
# EN — The deformed shape is drawn 50 times larger than life.
AMPLIFICATION = 50.0

# FR — Une face plane vaut zéro à l'arrondi près : on sélectionne une bande.
# EN — A flat face is zero up to rounding: a band is selected, not a value.
TOL = 1e-9  # m

Un acier, \( E = 200 \) GPa et \( \nu = 0{,}3 \) ; une masse de 2 500 kg, soit un poids de 24 525 N. PRESSION répartit ce poids sur la moitié basse de l’alésage. AMPLIFICATION ne sert qu’à la figure de la déformée : les déplacements réels, moins d’un millimètre, seraient invisibles à l’échelle de la pièce.

Le maillage

    # FR — Le maillage de tutoriel/maillage.py, tel quel, et sa peau.
    # EN — tutoriel/maillage.py's mesh, as is, and its skin.
    _, volume = structured_mesh(plot=False)
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))

Le volume HEX20 structuré de la page Maillage, importé tel quel, comme dans les pages de thermique. La peau donne les faces sur lesquelles s’intègrent les efforts répartis.

Les régions

    # FR — L'encastrement : les nœuds du plan x = 0, et les faces qu'ils portent
    #      pour la figure.
    # EN — The clamp: the nodes of the plane x = 0, and the faces they carry,
    #      for the figure.
    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)

    # FR — L'alésage : les faces de la peau sur le cylindre du trou ; puis sa
    #      moitié basse, z ≤ h/2.
    # EN — The bore: the skin's faces on the hole's cylinder; then its lower
    #      half, z ≤ h/2.
    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 — Le point de la masse : le nœud de l'alésage le plus proche de son
    #      point le plus bas, au milieu de l'épaisseur.
    # EN — The mass's point: the bore node closest to its lowest point, in the
    #      middle of the thickness.
    point_masse = alesage.nearest_node(
        [LENGTH, THICKNESS / 2.0, HEIGHT / 2.0 - HOLE_RADIUS]
    )
    poi_masse = pc.mesh.poi1_from_nodes([point_masse])

Trois régions, toutes repérées par leur géométrie :

  • l’encastrement, les 85 nœuds du plan \( x = 0 \), lus sur le volume ;
  • la moitié basse de l’alésage, 40 faces QUA8, celles de la peau dont les nœuds sont sur le cylindre du trou et sous son centre ;
  • le point de la masse, le nœud de l’alésage le plus proche de son point le plus bas, au milieu de l’épaisseur : \( (0{,}3 ; 0{,}01 ; 0{,}015) \).

Un blocage demande des nœuds, un effort réparti des faces. L’encastrement se pose donc sur un maillage POI1, la pression sur des QUA8. nearest_node rend un nœud du maillage, et poi1_from_nodes en fait un maillage d’un seul point, le support de la force ponctuelle.

    face_gauche.unit().face_color = ROUGE
    demi_alesage.unit().face_color = ORANGE
    poi_masse.unit().face_color = BLEU
    show(
        peau | face_gauche | demi_alesage | poi_masse,
        "Encastrement, moitié basse de l'alésage, point de la masse",
        "mecanique-elastique-regions.svg",
        wireframe=True,
    )

Le modèle

    # FR — L'élasticité en 3D : primales u_x, u_y, u_z, duales f_x, f_y, f_z.
    # EN — 3-D elasticity: primals u_x, u_y, u_z, duals f_x, f_y, f_z.
    fes = pc.FiniteElementSpace(volume)
    elasticite = pc.model.elasticity(fes, "full_3d")

    # FR — L'encastrement : un blocage par composante, chacun avec ses
    #      multiplicateurs, qui porteront les réactions.
    # EN — The clamp: one constraint per component, each with its
    #      multipliers, which will carry the reactions.
    blocage_x = pc.model.dirichlet(encastrement, elasticite, "u_x")
    blocage_y = pc.model.dirichlet(encastrement, elasticite, "u_y")
    blocage_z = pc.model.dirichlet(encastrement, elasticite, "u_z")
    modele = elasticite | blocage_x | blocage_y | blocage_z

    materiaux = pc.element_field.material_field(modele, [("E", E), ("nu", NU)])
    K = pc.matrix.stiffness(modele, materiaux)

pc.model.elasticity(fes, "full_3d") est l’élasticité en trois dimensions : ses inconnues sont u_x, u_y, u_z, et ses efforts f_x, f_y, f_z. L’encastrement est trois blocages, un par composante, réunis au modèle par |. Le champ matériau porte \( E \) et \( \nu \), et pc.matrix.stiffness assemble \( [K] \) avec les lignes des blocages.

Trois façons de poser la masse

Une force ponctuelle

    # FR — 1) Tout le poids sur un seul nœud, vers le bas.
    # EN — 1) The whole weight on a single node, downwards.
    force_ponctuelle = pc.NodeField(poi_masse, ["f_z"])
    force_ponctuelle[0].set_value(point_masse, "f_z", -POIDS)

Un champ aux nœuds sur le seul point de la masse, de composante f_z : tout le poids sur un nœud. C’est l’effort le plus simple à écrire, et le plus éloigné de la réalité : une masse ne s’appuie jamais sur un point.

Une pression

    # FR — 2) Une pression sur la moitié basse : une densité « phi_n » le long
    #      de la normale sortante n. Négative, elle pousse dans la matière.
    # EN — 2) A pressure on the lower half: a density "phi_n" along the
    #      outward normal n. Negative, it pushes into the material.
    demi_fes = pc.FiniteElementSpace(demi_alesage)
    pression = pc.model.flux(demi_fes, elasticite, "n")
    densite_pression = pc.element_field.material_field(pression, [("phi_n", -PRESSION)])
    force_pression = pc.node_field.external_forces(pression, densite_pression)

Un effort réparti est un terme du modèle, comme le flux imposé de la page Régime stationnaire : pc.model.flux sur l’espace des faces de la moitié basse. Sa composante "n" dit que la densité agit le long de la normale sortante. La densité phi_n est donc négative : la pression pousse contre la normale, dans la matière. pc.node_field.external_forces intègre \( \int [N]^T (-p\,n) \, dS \).

Une force surfacique verticale

    # FR — 3) La même densité, mais verticale : une composante « f_z » sur la
    #      même moitié.
    # EN — 3) The same density, but vertical: an "f_z" component on the same
    #      half.
    verticale = pc.model.flux(demi_fes, elasticite, "f_z")
    densite_verticale = pc.element_field.material_field(
        verticale, [("phi_f_z", -PRESSION)]
    )
    force_surfacique = pc.node_field.external_forces(verticale, densite_verticale)

Le même opérateur, la même densité, mais sur la composante "f_z" : l’effort est vertical en tout point de la moitié basse, quelle que soit l’orientation de la face.

Trois résolutions, une seule matrice

    # FR — Trois seconds membres, une seule matrice : la factorisation de K est
    #      faite au premier appel et reprise aux deux suivants.
    # EN — Three right-hand sides, a single matrix: K is factorized on the
    #      first call and reused on the next two.
    u_ponctuelle = pc.solver.solve(K, force_ponctuelle)
    u_pression = pc.solver.solve(K, force_pression)
    u_surfacique = pc.solver.solve(K, force_surfacique)

Un calcul linéaire se factorise une fois. Le premier solve factorise \( [K] \) et range la factorisation sur la matrice ; les deux suivants la reprennent et ne font qu’une descente-remontée chacun.

Les réactions

    # FR — Les réactions : les multiplicateurs λ de chaque blocage, lus sur
    #      ses nœuds multiplicateurs et sommés. λ est l'effort de la pièce sur
    #      le blocage ; l'encastrement, lui, pousse la pièce de −λ.
    # EN — The reactions: each constraint's multipliers λ, read on its
    #      multiplier nodes and summed. λ is the part's force on the
    #      constraint; the clamp pushes the part by −λ.
    reactions = []
    for u in (u_ponctuelle, u_pression, u_surfacique):
        r_x = -pc.node_field.restrict(u, blocage_x.multiplier_mesh()).sum("lambda")
        r_y = -pc.node_field.restrict(u, blocage_y.multiplier_mesh()).sum("lambda")
        r_z = -pc.node_field.restrict(u, blocage_z.multiplier_mesh()).sum("lambda")
        reactions.append((r_x, r_y, r_z))

Chaque blocage a ses nœuds multiplicateurs, que multiplier_mesh() rend. La composante lambda de la solution y porte les multiplicateurs ; restrict les isole, sum les additionne, et le signe moins en fait la réaction de l’encastrement sur la pièce. Le script affiche :

CasRéaction verticale
force ponctuelle24 525,0 N
pression15 613,1 N
force surfacique24 525,0 N

Les réactions horizontales sont nulles dans les trois cas.

Une pression n’est pas un poids. La force ponctuelle et la force surfacique portent tout le poids, et l’encastrement le rend exactement. La pression ne porte que 15 613,1 N, soit \( 2mg/\pi \). Elle est perpendiculaire à la paroi : sur le demi-cylindre, ses parties horizontales s’annulent d’un côté à l’autre, et sa partie verticale ne fait que \( 2 p r e \), l’aire de la moitié basse vue d’en dessous fois \( p \), au lieu de \( \pi r e \, p \). Pour qu’une pression porte la masse entière, il faudrait la prendre \( \pi / 2 \) fois plus forte.

La déformée

    # FR — Une seconde configuration des mêmes nœuds, posée à X + 50·u ; le
    #      bloc `with` la rend active le temps de la figure.
    # EN — A second configuration of the same nodes, laid at X + 50·u; the
    #      `with` block makes it active for the time of the figure.
    coords = volume.coords()
    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(u_surfacique * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        volume.plot(
            view=VUE,
            title="Force surfacique — déformée ×50, u_z (mm)",
            field=u_surfacique * 1000.0,
            component="u_z",
            cmap="viridis",
            save=os.path.join(OUT, "mecanique-elastique-deformee.svg") if OUT else None,
        )

Une configuration est un jeu complet de positions pour les mêmes nœuds. add_config en ajoute une au Coords de la pièce, et pc.coords.displace_from la pose en \( X + 50\,u \) à partir de la configuration 0, celle de référence. Les nœuds multiplicateurs de la solution n’ont pas de déplacement : ils restent où ils sont.

Le bloc with est une construction Python : il rend la configuration déformée active le temps du tracé, puis remet celle d’avant, même si le tracé échoue. Le maillage n’est pas copié : ce sont les mêmes nœuds, lus à d’autres positions.

La chape fléchit comme une poutre encastrée : rien ne bouge à gauche, et le bout libre descend. Le point le plus bas descend de 0,978 mm sous la force surfacique, de 0,970 mm sous la force ponctuelle et de 0,623 mm sous la pression. Ce dernier rapport, 0,623 / 0,978, vaut à peu près \( 2/\pi \) : c’est celui des efforts appliqués, et c’est ce qu’on attend d’un calcul linéaire.

Déformations, contraintes, von Mises

    # FR — Aux points de Gauss : la déformation ε = [B]{U}, la contrainte
    #      σ = [C] ε, puis la contrainte équivalente de von Mises.
    # EN — At the Gauss points: the strain ε = [B]{U}, the stress σ = [C] ε,
    #      then the von Mises equivalent stress.
    deformation = pc.element_field.strain(modele, u_surfacique, materiaux)
    contrainte = pc.element_field.elastic_stress(modele, deformation, materiaux)
    von_mises = pc.element_field.von_mises(modele, contrainte)

    # FR — En MPa pour les figures : un champ se multiplie comme un nombre.
    # EN — In MPa for the figures: a field multiplies like a number.
    von_mises_mpa = von_mises * 1e-6
    volume.plot(
        view=VUE,
        title="Force surfacique — von Mises (MPa)",
        field=von_mises_mpa,
        component="von_mises",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-elastique-von-mises.svg") if OUT else None,
    )

strain calcule \( \varepsilon = [B]\{U\} \) aux points de Gauss, elastic_stress \( \sigma = [C]\,\varepsilon \), et von_mises la contrainte équivalente. Ce sont des champs par éléments : chaque élément garde ses propres valeurs, sans les partager avec ses voisins. Un champ se multiplie par un nombre comme un nombre : von_mises * 1e-6 le passe en MPa.

von_mises prend aussi six coefficients facultatifs, coefficients=[F, G, H, L, M, N]. Ceux par défaut donnent von Mises ; d’autres donnent la contrainte équivalente de Hill, pour un matériau qui ne résiste pas de la même façon dans toutes les directions.

Aux points de Gauss, von Mises va de 3,44 à 226,69 MPa. Le maximum est au haut et au bas de l’encastrement, là où la flexion tend et comprime le plus la matière, et autour de l’alésage, là où la charge entre dans la pièce. Le minimum est à mi-hauteur, sur la ligne où la flexion change de signe.

La couleur d’un élément vient de ses propres points de Gauss. Pour peindre un élément, pyrucast ajuste au mieux une fonction de cet élément sur les valeurs de ses points de Gauss, puis la lit aux nœuds. Lue au bord de l’élément, cette fonction peut sortir un peu de la plage des points de Gauss : la légende de la carte descend légèrement sous zéro, alors que von Mises est positif en tout point de Gauss.

Le long d’une ligne

Le bord de l’encastrement

    # FR — Le bord x = 0, y = 0, de bas en haut : 101 points sur une droite,
    #      un maillage POI1 dans l'ordre. Chaque point prend la valeur de
    #      l'élément qui le contient, tirée de ses seuls points de Gauss.
    # EN — The edge x = 0, y = 0, bottom to top: 101 points on a straight
    #      line, a POI1 mesh in order. Each point takes the value of the
    #      element holding it, drawn from its own Gauss points only.
    points_bord = []
    for i in range(101):
        points_bord.append(coords.add_node([0.0, 0.0, HEIGHT * i / 100.0]))
    bord = pc.mesh.poi1_from_nodes(points_bord)
    vm_bord = pc.node_field.project(von_mises_mpa, volume, bord)
    courbe_bord = vm_bord.along(bord)
    courbe_bord.plot(
        x_label="z (m)",
        y_label="von Mises (MPa)",
        title="von Mises le long du bord x = 0, y = 0",
        save=os.path.join(OUT, "mecanique-elastique-bord.svg") if OUT else None,
    )

Une ligne de tracé est un maillage POI1 dont les points se suivent dans l’ordre. Ici, 101 nœuds ajoutés au Coords de la pièce, du bas au haut du bord \( x = 0 \), \( y = 0 \). Ils ne font partie d’aucune maille : ce sont de simples points de lecture.

pc.node_field.project donne à chaque point la valeur de l’élément qui le contient, tirée de ses seuls points de Gauss par le même ajustement que la carte : jamais de moyenne entre éléments voisins. along, appelée comme une méthode du champ obtenu, en fait une évolution : l’abscisse est la distance parcourue sur la ligne, l’ordonnée la valeur en chaque point.

La courbe est un V : la flexion est la plus forte en haut et en bas, et s’annule à mi-hauteur. Le long du bord, von Mises va de −0,30 à 238,35 MPa. Ces deux valeurs sortent de la plage des points de Gauss, et pour la même raison que les couleurs de la carte :

  • le bord est une arête de la pièce, au-delà des points de Gauss de ses éléments, et la valeur y est prolongée ;
  • dans le coin de l’encastrement, où les contraintes montent vite, le maximum dépasse un peu celui des points de Gauss ;
  • à mi-hauteur, où von Mises passe par zéro en pointe, la fonction de l’élément arrondit la pointe et passe un peu sous zéro.

Une ligne quelconque : la sinusoïde

    # FR — Une ligne quelconque : une sinusoïde dans le plan médian y = e/2,
    #      de x = 0,01 L à 0,9 L, en 90 points. Elle traverse des éléments :
    #      la courbe saute à chaque frontière.
    # EN — Any line: a sinusoid in the mid-plane y = e/2, from x = 0.01 L to
    #      0.9 L, in 90 points. It crosses elements: the curve jumps at each
    #      boundary.
    points_sinusoide = []
    for i in range(1, 91):
        x = 0.01 * i * LENGTH
        z = HEIGHT / 2.0 + 0.4 * HEIGHT * math.sin(math.radians(3000.0 * x))
        points_sinusoide.append(coords.add_node([x, THICKNESS / 2.0, z]))
    sinusoide = pc.mesh.poi1_from_nodes(points_sinusoide)
    sinusoide.unit().face_color = ROUGE
    show(peau | sinusoide, "La sinusoïde", "mecanique-elastique-sinusoide.svg", True)
    vm_sinusoide = pc.node_field.project(von_mises_mpa, volume, sinusoide)
    courbe_sinusoide = vm_sinusoide.along(sinusoide)
    courbe_sinusoide.plot(
        x_label="abscisse curviligne (m)",
        y_label="von Mises (MPa)",
        title="von Mises le long de la sinusoïde",
        save=os.path.join(OUT, "mecanique-elastique-courbe.svg") if OUT else None,
    )

La même démarche sur une ligne qui traverse la pièce : 90 points sur une sinusoïde du plan médian \( y = e/2 \), de \( x = 0{,}01\,L \) à \( 0{,}9\,L \). La ligne se trace comme un maillage, d’une couleur à elle.

Le long de la sinusoïde, von Mises va de 4,55 à 160,58 MPa. Chaque bosse de la courbe est un passage de la ligne près du haut ou du bas de la pièce, là où la flexion est forte, et chaque creux un passage à mi-hauteur. Les bosses décroissent de gauche à droite : la flexion est la plus forte à l’encastrement et diminue vers le bout libre. Un point prend la valeur de l’élément qui le contient, si bien que la courbe peut sauter d’un élément au suivant. Ici, les sauts restent petits.

Script complet

"""Tutoriel débutant — 9.7.1 Élasticité. / Beginner tutorial — 9.7.1 Elasticity.

FR — Reprend la **chape percée** de la page « Maillage » (le volume HEX20
structuré de `tutoriel/maillage.py`), l'encastre par sa face gauche et y
suspend une masse de 2 500 kg par l'alésage. La masse est posée de trois
façons :

1. une **force ponctuelle** au bas de l'alésage ;
2. une **pression** sur la moitié basse de l'alésage ;
3. une **force surfacique verticale** sur la même moitié.

Les trois cas se résolvent sur la même matrice de rigidité. On lit ensuite les
réactions de l'encastrement, la déformée amplifiée, les déformations, les
contraintes et la contrainte de von Mises, puis von Mises le long de deux
lignes. Le détail pas à pas est dans le livre, page « Élasticité ».

EN — Picks the meshing page's **pierced lug** back up (the structured HEX20
volume of `tutoriel/maillage.py`), clamps it by its left face and hangs a
2,500 kg mass from its bore. The mass is laid on in three ways:

1. a **point force** at the bottom of the bore;
2. a **pressure** on the lower half of the bore;
3. a **vertical surface force** on the same half.

The three cases are solved on the same stiffness matrix. Then come the
clamp's reactions, the magnified deformed shape, the strains, the stresses and
the von Mises stress, and von Mises along two lines. The step-by-step
walkthrough lives in the book's "Élasticité" page.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/mecanique_elastique.py

    # Figures du livre / book figures (book/src/tutoriel/img/) :
    # PYRUCAST_TUTORIEL_IMG_DIR=book/src/tutoriel/img python tutoriel/mecanique_elastique.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)

# FR — Une couleur par région, tenue d'une figure à l'autre.
# EN — One colour per region, kept from one figure to the next.
ROUGE = (255, 0, 0)  # encastrement / clamp
ORANGE = (255, 140, 0)  # moitié basse de l'alésage / lower half of the bore
BLEU = (0, 0, 255)  # point de la masse / the mass's point

# ── Données physiques / Physical data ──────────────────────────────────────
E, NU = 200e9, 0.3  # Pa, — : un acier / a steel
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N

# FR — Le poids réparti sur la moitié basse de l'alésage, d'aire π·r·e.
# EN — The weight spread over the lower half of the bore, of area π·r·e.
PRESSION = POIDS / (math.pi * HOLE_RADIUS * THICKNESS)  # Pa

# FR — La déformée est tracée 50 fois plus grande que nature.
# EN — The deformed shape is drawn 50 times larger than life.
AMPLIFICATION = 50.0

# FR — Une face plane vaut zéro à l'arrondi près : on sélectionne une bande.
# EN — A flat face is zero up to rounding: a band is selected, not a value.
TOL = 1e-9  # m


def main() -> None:
    # FR — Le maillage de tutoriel/maillage.py, tel quel, et sa peau.
    # EN — tutoriel/maillage.py's mesh, as is, and its skin.
    _, volume = structured_mesh(plot=False)
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))

    # ── Régions / Regions ───────────────────────────────────────────────────
    # FR — L'encastrement : les nœuds du plan x = 0, et les faces qu'ils portent
    #      pour la figure.
    # EN — The clamp: the nodes of the plane x = 0, and the faces they carry,
    #      for the figure.
    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)

    # FR — L'alésage : les faces de la peau sur le cylindre du trou ; puis sa
    #      moitié basse, z ≤ h/2.
    # EN — The bore: the skin's faces on the hole's cylinder; then its lower
    #      half, z ≤ h/2.
    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 — Le point de la masse : le nœud de l'alésage le plus proche de son
    #      point le plus bas, au milieu de l'épaisseur.
    # EN — The mass's point: the bore node closest to its lowest point, in the
    #      middle of the thickness.
    point_masse = alesage.nearest_node(
        [LENGTH, THICKNESS / 2.0, HEIGHT / 2.0 - HOLE_RADIUS]
    )
    poi_masse = pc.mesh.poi1_from_nodes([point_masse])

    face_gauche.unit().face_color = ROUGE
    demi_alesage.unit().face_color = ORANGE
    poi_masse.unit().face_color = BLEU
    show(
        peau | face_gauche | demi_alesage | poi_masse,
        "Encastrement, moitié basse de l'alésage, point de la masse",
        "mecanique-elastique-regions.svg",
        wireframe=True,
    )

    # ── Modèle / Model ──────────────────────────────────────────────────────
    # FR — L'élasticité en 3D : primales u_x, u_y, u_z, duales f_x, f_y, f_z.
    # EN — 3-D elasticity: primals u_x, u_y, u_z, duals f_x, f_y, f_z.
    fes = pc.FiniteElementSpace(volume)
    elasticite = pc.model.elasticity(fes, "full_3d")

    # FR — L'encastrement : un blocage par composante, chacun avec ses
    #      multiplicateurs, qui porteront les réactions.
    # EN — The clamp: one constraint per component, each with its
    #      multipliers, which will carry the reactions.
    blocage_x = pc.model.dirichlet(encastrement, elasticite, "u_x")
    blocage_y = pc.model.dirichlet(encastrement, elasticite, "u_y")
    blocage_z = pc.model.dirichlet(encastrement, elasticite, "u_z")
    modele = elasticite | blocage_x | blocage_y | blocage_z

    materiaux = pc.element_field.material_field(modele, [("E", E), ("nu", NU)])
    K = pc.matrix.stiffness(modele, materiaux)

    # ── Trois façons de poser la masse / Three ways to lay the mass on ──────
    # FR — 1) Tout le poids sur un seul nœud, vers le bas.
    # EN — 1) The whole weight on a single node, downwards.
    force_ponctuelle = pc.NodeField(poi_masse, ["f_z"])
    force_ponctuelle[0].set_value(point_masse, "f_z", -POIDS)

    # FR — 2) Une pression sur la moitié basse : une densité « phi_n » le long
    #      de la normale sortante n. Négative, elle pousse dans la matière.
    # EN — 2) A pressure on the lower half: a density "phi_n" along the
    #      outward normal n. Negative, it pushes into the material.
    demi_fes = pc.FiniteElementSpace(demi_alesage)
    pression = pc.model.flux(demi_fes, elasticite, "n")
    densite_pression = pc.element_field.material_field(pression, [("phi_n", -PRESSION)])
    force_pression = pc.node_field.external_forces(pression, densite_pression)

    # FR — 3) La même densité, mais verticale : une composante « f_z » sur la
    #      même moitié.
    # EN — 3) The same density, but vertical: an "f_z" component on the same
    #      half.
    verticale = pc.model.flux(demi_fes, elasticite, "f_z")
    densite_verticale = pc.element_field.material_field(
        verticale, [("phi_f_z", -PRESSION)]
    )
    force_surfacique = pc.node_field.external_forces(verticale, densite_verticale)

    # FR — Trois seconds membres, une seule matrice : la factorisation de K est
    #      faite au premier appel et reprise aux deux suivants.
    # EN — Three right-hand sides, a single matrix: K is factorized on the
    #      first call and reused on the next two.
    u_ponctuelle = pc.solver.solve(K, force_ponctuelle)
    u_pression = pc.solver.solve(K, force_pression)
    u_surfacique = pc.solver.solve(K, force_surfacique)

    # FR — Les réactions : les multiplicateurs λ de chaque blocage, lus sur
    #      ses nœuds multiplicateurs et sommés. λ est l'effort de la pièce sur
    #      le blocage ; l'encastrement, lui, pousse la pièce de −λ.
    # EN — The reactions: each constraint's multipliers λ, read on its
    #      multiplier nodes and summed. λ is the part's force on the
    #      constraint; the clamp pushes the part by −λ.
    reactions = []
    for u in (u_ponctuelle, u_pression, u_surfacique):
        r_x = -pc.node_field.restrict(u, blocage_x.multiplier_mesh()).sum("lambda")
        r_y = -pc.node_field.restrict(u, blocage_y.multiplier_mesh()).sum("lambda")
        r_z = -pc.node_field.restrict(u, blocage_z.multiplier_mesh()).sum("lambda")
        reactions.append((r_x, r_y, r_z))

    # FR — Une seconde configuration des mêmes nœuds, posée à X + 50·u ; le
    #      bloc `with` la rend active le temps de la figure.
    # EN — A second configuration of the same nodes, laid at X + 50·u; the
    #      `with` block makes it active for the time of the figure.
    coords = volume.coords()
    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(u_surfacique * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        volume.plot(
            view=VUE,
            title="Force surfacique — déformée ×50, u_z (mm)",
            field=u_surfacique * 1000.0,
            component="u_z",
            cmap="viridis",
            save=os.path.join(OUT, "mecanique-elastique-deformee.svg") if OUT else None,
        )

    # FR — Aux points de Gauss : la déformation ε = [B]{U}, la contrainte
    #      σ = [C] ε, puis la contrainte équivalente de von Mises.
    # EN — At the Gauss points: the strain ε = [B]{U}, the stress σ = [C] ε,
    #      then the von Mises equivalent stress.
    deformation = pc.element_field.strain(modele, u_surfacique, materiaux)
    contrainte = pc.element_field.elastic_stress(modele, deformation, materiaux)
    von_mises = pc.element_field.von_mises(modele, contrainte)

    # FR — En MPa pour les figures : un champ se multiplie comme un nombre.
    # EN — In MPa for the figures: a field multiplies like a number.
    von_mises_mpa = von_mises * 1e-6
    volume.plot(
        view=VUE,
        title="Force surfacique — von Mises (MPa)",
        field=von_mises_mpa,
        component="von_mises",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-elastique-von-mises.svg") if OUT else None,
    )

    # FR — Le bord x = 0, y = 0, de bas en haut : 101 points sur une droite,
    #      un maillage POI1 dans l'ordre. Chaque point prend la valeur de
    #      l'élément qui le contient, tirée de ses seuls points de Gauss.
    # EN — The edge x = 0, y = 0, bottom to top: 101 points on a straight
    #      line, a POI1 mesh in order. Each point takes the value of the
    #      element holding it, drawn from its own Gauss points only.
    points_bord = []
    for i in range(101):
        points_bord.append(coords.add_node([0.0, 0.0, HEIGHT * i / 100.0]))
    bord = pc.mesh.poi1_from_nodes(points_bord)
    vm_bord = pc.node_field.project(von_mises_mpa, volume, bord)
    courbe_bord = vm_bord.along(bord)
    courbe_bord.plot(
        x_label="z (m)",
        y_label="von Mises (MPa)",
        title="von Mises le long du bord x = 0, y = 0",
        save=os.path.join(OUT, "mecanique-elastique-bord.svg") if OUT else None,
    )

    # FR — Une ligne quelconque : une sinusoïde dans le plan médian y = e/2,
    #      de x = 0,01 L à 0,9 L, en 90 points. Elle traverse des éléments :
    #      la courbe saute à chaque frontière.
    # EN — Any line: a sinusoid in the mid-plane y = e/2, from x = 0.01 L to
    #      0.9 L, in 90 points. It crosses elements: the curve jumps at each
    #      boundary.
    points_sinusoide = []
    for i in range(1, 91):
        x = 0.01 * i * LENGTH
        z = HEIGHT / 2.0 + 0.4 * HEIGHT * math.sin(math.radians(3000.0 * x))
        points_sinusoide.append(coords.add_node([x, THICKNESS / 2.0, z]))
    sinusoide = pc.mesh.poi1_from_nodes(points_sinusoide)
    sinusoide.unit().face_color = ROUGE
    show(peau | sinusoide, "La sinusoïde", "mecanique-elastique-sinusoide.svg", True)
    vm_sinusoide = pc.node_field.project(von_mises_mpa, volume, sinusoide)
    courbe_sinusoide = vm_sinusoide.along(sinusoide)
    courbe_sinusoide.plot(
        x_label="abscisse curviligne (m)",
        y_label="von Mises (MPa)",
        title="von Mises le long de la sinusoïde",
        save=os.path.join(OUT, "mecanique-elastique-courbe.svg") if OUT else None,
    )

    print(
        f"volume : {volume.cell_count} HEX20, encastrement : {encastrement.cell_count} nœuds"
    )
    print(f"moitié basse de l'alésage : {demi_alesage.cell_count} QUA8")
    print(f"point de la masse : {point_masse.position()}")
    print(f"poids : {POIDS:.1f} N, 2·poids/π : {2.0 * POIDS / math.pi:.1f} N")
    for nom, u, (r_x, r_y, r_z) in zip(
        ("force ponctuelle", "pression", "force surfacique"),
        (u_ponctuelle, u_pression, u_surfacique),
        reactions,
    ):
        print(
            f"{nom} : réactions {r_x:.1f} {r_y:.1f} {r_z:.1f} N ; "
            f"u_z le plus bas {u.min('u_z') * 1000.0:.3f} mm"
        )
    print(
        f"force surfacique, aux points de Gauss : von Mises de "
        f"{von_mises_mpa.min('von_mises'):.2f} à {von_mises_mpa.max('von_mises'):.2f} MPa"
    )
    print(
        f"le long du bord : von Mises de {vm_bord.min('von_mises'):.2f} "
        f"à {vm_bord.max('von_mises'):.2f} MPa"
    )
    print(
        f"le long de la sinusoïde : von Mises de {vm_sinusoide.min('von_mises'):.2f} "
        f"à {vm_sinusoide.max('von_mises'):.2f} MPa"
    )


if __name__ == "__main__":
    main()

Suite : Dilatation, matériau homogène.

Dilatation, matériau homogène

La chape de la page Élasticité, encastrée et chargée par la force surfacique verticale, est maintenant chaude : 250 °C à l’alésage, une vingtaine de degrés près de l’encastrement. C’est la température du dernier instant du calcul de la page Thermique non linéaire, relue dans le fichier qu’il a écrit. La pièce se dilate, l’encastrement l’en empêche en partie, et des contraintes naissent sans aucun effort de plus.

Le script complet est tutoriel/mecanique_dilatation.py ; tous les extraits ci-dessous en sont issus directement, dans l’ordre du fichier. Il relit le fichier thermique.pyr qu’écrit tutoriel/thermique_non_lineaire.py : ce script-là doit avoir tourné avant.

L’équation résolue

Au système de la page Élasticité s’ajoute le terme de dilatation de la page Mécanique linéaire :

\[ [K]\{U\} = \{F_s\} + \{F_{\text{th}}\} + \{F_{\text{reac}}\}, \qquad \{F_{\text{th}}\} = \int_V [B]^T [C]\,\varepsilon_{\text{th}} \, dV, \qquad \varepsilon_{\text{th}} = \alpha\,(T - T_{\text{ref}})\,I \]

\( [C]\,\varepsilon_{\text{th}} \) est la contrainte qu’il faudrait pour empêcher toute dilatation ; \( \{F_{\text{th}}\} \) en sont les forces nodales. Le système résolu est le même que pour l’élasticité, avec ce second membre :

\[ \begin{bmatrix} K & A^T \\ A & 0 \end{bmatrix} \begin{Bmatrix} U \\ \lambda \end{Bmatrix} = \begin{Bmatrix} F_s + F_{\text{th}} \\ 0 \end{Bmatrix} \]

Seule la part de la déformation que la dilatation n’explique pas crée une contrainte :

\[ \sigma = [C] \left( [B]\{U\} - \varepsilon_{\text{th}} \right) \]

La dilatation ne pousse pas la pièce dans son ensemble. Les forces de \( \{F_{\text{th}}\} \) se compensent : leur somme est nulle, quelle que soit la température. Une pièce libre se dilaterait sans bouger dans son ensemble ; l’encastrement, lui, la retient, et ses réactions changent nœud par nœud. Leur somme, elle, reste le poids.

Chaque terme correspond à un objet du script :

Termepyrucast
\( T \) aux points de Gausspc.element_field.interp_to_gauss(temperature, fes)
\( \varepsilon_{\text{th}} = \alpha\,(T - T_{\text{ref}})\,I \)pc.element_field.thermal_strain(t_gauss, materiaux, fes, T_REF), qui lit alpha dans le champ matériau
\( [C]\,\varepsilon_{\text{th}} \)pc.element_field.elastic_stress(modele, eps_th, materiaux)
\( \int_V [B]^T [C]\,\varepsilon_{\text{th}} \, dV \)pc.node_field.divergence(sigma_th, "sigma")
\( \sigma = [C](\varepsilon - \varepsilon_{\text{th}}) \)pc.element_field.elastic_stress(modele, deformation - eps_th, materiaux)

Les données du calcul

E, NU = 200e9, 0.3  # Pa, — : un acier / a steel
ALPHA = 1e-5  # 1/K : coefficient de dilatation / expansion coefficient
T_REF = 0.0  # °C : la pièce n'est pas dilatée à 0 °C / not expanded at 0 °C
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N
PRESSION = POIDS / (math.pi * HOLE_RADIUS * THICKNESS)  # Pa
AMPLIFICATION = 50.0

# FR — Le fichier écrit par tutoriel/thermique_non_lineaire.py.
# EN — The file tutoriel/thermique_non_lineaire.py writes.
FICHIER = os.path.join(tempfile.gettempdir(), "thermique.pyr")

TOL = 1e-9  # m

Les données de la page Élasticité, plus le coefficient de dilatation d’un acier, \( \alpha = 10^{-5} \) /K, et la température \( T_{\text{ref}} = 0 \) °C à laquelle la pièce n’est pas dilatée. FICHIER est le chemin où tutoriel/thermique_non_lineaire.py a écrit son calcul, dans le dossier temporaire du système.

La température relue

    # FR — Le volume et l'évolution des températures, relus sous les clefs
    #      où ils ont été écrits ; la température du dernier instant, en °C.
    # EN — The volume and the temperature evolution, read back under the
    #      keys they were written under; the last instant's temperature, in °C.
    if not os.path.exists(FICHIER):
        raise SystemExit(
            f"{FICHIER} manque : lancer d'abord tutoriel/thermique_non_lineaire.py"
        )
    relu = pc.load(FICHIER)
    volume = relu["volume"]
    evolution = relu["température (°C)"]
    temperature = evolution.frames()[-1]
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))
    volume.plot(
        view=VUE,
        title="Température relue, dernier instant (°C)",
        field=temperature,
        component="T",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-dilatation-temperature.svg") if OUT else None,
    )

pc.load rend un dictionnaire, avec les clefs sous lesquelles pc.save a écrit : le volume, et l’évolution des 101 champs de température. frames() en donne les champs dans l’ordre du temps, et le dernier est celui de 15 000 s. Le volume relu est celui du calcul thermique, avec ses propres nœuds : tout ce qui suit se construit sur lui, et la température s’y lit sans rien convertir.

Une thermique et une mécanique se chaînent par un fichier. Le calcul thermique est fait une fois ; la mécanique le relit autant de fois qu’il le faut, sans le refaire.

La température relue va de 21,67 à 250,00 °C : l’alésage à 250 °C, le coin bas de l’encastrement le plus froid, refroidi à la fois par le flux qui sort de la face gauche et par le film convectif du dessous.

Les régions et le modèle

    # FR — Les régions de la page « Élasticité », retrouvées sur le volume
    #      relu : l'encastrement et la moitié basse de l'alésage.
    # EN — The "Élasticité" page's regions, found again on the volume read
    #      back: the clamp and the lower half of the bore.
    encastrement = pc.mesh.points_on_plane(volume, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    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é, retrouvées sur le volume relu par les mêmes sélections géométriques.

    # FR — Le modèle de la page « Élasticité » ; le matériau porte en plus
    #      « alpha », que la dilatation lira.
    # EN — The "Élasticité" page's model; the material also carries "alpha",
    #      which the expansion will read.
    fes = pc.FiniteElementSpace(volume)
    elasticite = pc.model.elasticity(fes, "full_3d")
    blocage_x = pc.model.dirichlet(encastrement, elasticite, "u_x")
    blocage_y = pc.model.dirichlet(encastrement, elasticite, "u_y")
    blocage_z = pc.model.dirichlet(encastrement, elasticite, "u_z")
    modele = elasticite | blocage_x | blocage_y | blocage_z
    materiaux = pc.element_field.material_field(
        modele, [("E", E), ("nu", NU), ("alpha", ALPHA)]
    )
    K = pc.matrix.stiffness(modele, materiaux)

    demi_fes = pc.FiniteElementSpace(demi_alesage)
    verticale = pc.model.flux(demi_fes, elasticite, "f_z")
    densite_verticale = pc.element_field.material_field(
        verticale, [("phi_f_z", -PRESSION)]
    )
    force_surfacique = pc.node_field.external_forces(verticale, densite_verticale)

Le modèle, l’encastrement et la force surfacique sont ceux de la page Élasticité. Le champ matériau porte en plus alpha, que la dilatation va lire. L’élasticité, elle, ne lit que E et nu : alpha ne change pas \( [K] \).

La dilatation

    # FR — La température aux points de Gauss, puis la dilatation
    #      ε_th = α (T − T_ref), la contrainte qu'elle donnerait si rien ne la
    #      gênait, [C] ε_th, et ses forces nodales ∫ [B]ᵀ [C] ε_th dV.
    # EN — The temperature at the Gauss points, then the expansion
    #      ε_th = α (T − T_ref), the stress it would give were nothing in its
    #      way, [C] ε_th, and its nodal forces ∫ [B]ᵀ [C] ε_th dV.
    t_gauss = pc.element_field.interp_to_gauss(temperature, fes)
    eps_th = pc.element_field.thermal_strain(t_gauss, materiaux, fes, T_REF)
    sigma_th = pc.element_field.elastic_stress(modele, eps_th, materiaux)
    force_th = (
        pc.node_field.divergence(sigma_th, "sigma")
        .rename_component("div_sigma_x", "f_x")
        .rename_component("div_sigma_y", "f_y")
        .rename_component("div_sigma_z", "f_z")
    )

Quatre lignes, une par terme du tableau ci-dessus. La température vient d’un calcul aux nœuds ; interp_to_gauss la porte aux points de Gauss, là où thermal_strain calcule \( \alpha\,(T - T_{\text{ref}}) \). elastic_stress en fait \( [C]\,\varepsilon_{\text{th}} \), et divergence intègre \( \int [B]^T \sigma \, dV \) sur toute la pièce.

divergence nomme ses composantes d’après celles du champ qu’elle reçoit : div_sigma_x, div_sigma_y, div_sigma_z. Le second membre de l’élasticité attend f_x, f_y, f_z : rename_component les renomme.

Le second membre

    # FR — Les deux chargements partagent les nœuds de l'alésage : la force
    #      surfacique est ramenée sur le support de la dilatation, puis `+`
    #      somme nœud à nœud.
    # EN — Both loads share the bore's nodes: the surface force is brought
    #      onto the expansion's support, then `+` sums node by node.
    second_membre = force_th + pc.node_field.restrict_like(force_surfacique, force_th)

Les deux chargements touchent les mêmes nœuds, ceux de la moitié basse de l’alésage. Comme à la page Régime stationnaire, leurs contributions doivent s’y additionner, ce que | ne fait pas. restrict_like ramène la force surfacique sur le support de la dilatation, tous les nœuds de la pièce ; les deux champs ont alors le même support, et + les somme nœud par nœud.

Résolution

    u = pc.solver.solve(K, second_membre)
    r_x = -pc.node_field.restrict(u, blocage_x.multiplier_mesh()).sum("lambda")
    r_y = -pc.node_field.restrict(u, blocage_y.multiplier_mesh()).sum("lambda")
    r_z = -pc.node_field.restrict(u, blocage_z.multiplier_mesh()).sum("lambda")

Les réactions se lisent comme à la page Élasticité. Le script affiche une somme des forces de la dilatation de l’ordre de \( 10^{-9} \) N, zéro à l’arrondi près, et des réactions de 0, 0 et 24 525,0 N : exactement le poids, comme sans dilatation.

La déformée

    coords = volume.coords()
    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(u * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        volume.plot(
            view=VUE,
            title="Dilatation et force surfacique — déformée ×50, u_x (mm)",
            field=u * 1000.0,
            component="u_x",
            cmap="viridis",
            save=os.path.join(OUT, "mecanique-dilatation-deformee.svg")
            if OUT
            else None,
        )

La pièce s’allonge : le bout libre avance de 0,760 mm selon \( x \). Chaque tranche s’allonge selon sa température, et les allongements s’additionnent de l’encastrement jusqu’au bout libre. Elle descend aussi davantage : le point le plus bas est à −1,280 mm, contre −0,978 mm sous la seule force surfacique. Le dessous, refroidi par le film convectif, se dilate moins que le dessus : la pièce se cintre vers le bas, dans le même sens que la masse.

Les contraintes

    # FR — Seule la part de la déformation que la dilatation n'explique pas
    #      crée une contrainte : σ = [C] (ε − ε_th).
    # EN — Only the part of the strain the expansion does not account for
    #      creates a stress: σ = [C] (ε − ε_th).
    deformation = pc.element_field.strain(modele, u, materiaux)
    contrainte = pc.element_field.elastic_stress(
        modele, deformation - eps_th, materiaux
    )
    von_mises_mpa = pc.element_field.von_mises(modele, contrainte) * 1e-6
    volume.plot(
        view=VUE,
        title="Dilatation et force surfacique — von Mises (MPa)",
        field=von_mises_mpa,
        component="von_mises",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-dilatation-von-mises.svg") if OUT else None,
    )

Aux points de Gauss, von Mises va de 3,92 à 379,19 MPa, contre 226,69 MPa au plus sans dilatation. Le maximum est au coin bas de l’encastrement : la face \( x = 0 \) ne peut pas se dilater, et le bas y est en plus comprimé par la flexion.

Un calcul élastique peut dépasser la limite du matériau. 379 MPa, c’est plus que les 250 MPa à partir desquels l’acier de la page Plasticité se déforme de façon permanente. Le calcul élastique ne le sait pas : il prolonge la loi de Hooke aussi loin qu’il le faut. Ce dépassement dit seulement que la pièce, en réalité, plastifierait.

Script complet

"""Tutoriel débutant — 9.7.2 Dilatation, matériau homogène. / Beginner
tutorial — 9.7.2 Thermal expansion, homogeneous material.

FR — Reprend la chape encastrée de la page « Élasticité », chargée par la
force surfacique verticale, et y ajoute la **dilatation thermique**. La
température est celle du dernier instant du calcul avec rayonnement, relue
dans le fichier qu'écrit `tutoriel/thermique_non_lineaire.py` : ce script-là
doit avoir tourné avant. La pièce se dilate de `α (T − T_ref)`, l'encastrement
l'en empêche en partie, et des contraintes naissent. Le détail pas à pas est
dans le livre, page « Dilatation, matériau homogène ».

EN — Takes the clamped lug of the "Élasticité" page back, loaded by the
vertical surface force, and adds **thermal expansion**. The temperature is
the last instant of the analysis with radiation, read back from the file
`tutoriel/thermique_non_lineaire.py` writes: that script must have run
first. The part expands by `α (T − T_ref)`, the clamp partly prevents it,
and stresses arise. The step-by-step walkthrough lives in the book's
"Dilatation, matériau homogène" page.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/thermique_non_lineaire.py   # écrit / writes thermique.pyr
    python tutoriel/mecanique_dilatation.py

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

import math
import os
import tempfile

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

# 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)

# ── Données physiques / Physical data ──────────────────────────────────────
E, NU = 200e9, 0.3  # Pa, — : un acier / a steel
ALPHA = 1e-5  # 1/K : coefficient de dilatation / expansion coefficient
T_REF = 0.0  # °C : la pièce n'est pas dilatée à 0 °C / not expanded at 0 °C
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N
PRESSION = POIDS / (math.pi * HOLE_RADIUS * THICKNESS)  # Pa
AMPLIFICATION = 50.0

# FR — Le fichier écrit par tutoriel/thermique_non_lineaire.py.
# EN — The file tutoriel/thermique_non_lineaire.py writes.
FICHIER = os.path.join(tempfile.gettempdir(), "thermique.pyr")

TOL = 1e-9  # m


def main() -> None:
    # FR — Le volume et l'évolution des températures, relus sous les clefs
    #      où ils ont été écrits ; la température du dernier instant, en °C.
    # EN — The volume and the temperature evolution, read back under the
    #      keys they were written under; the last instant's temperature, in °C.
    if not os.path.exists(FICHIER):
        raise SystemExit(
            f"{FICHIER} manque : lancer d'abord tutoriel/thermique_non_lineaire.py"
        )
    relu = pc.load(FICHIER)
    volume = relu["volume"]
    evolution = relu["température (°C)"]
    temperature = evolution.frames()[-1]
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))
    volume.plot(
        view=VUE,
        title="Température relue, dernier instant (°C)",
        field=temperature,
        component="T",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-dilatation-temperature.svg") if OUT else None,
    )

    # FR — Les régions de la page « Élasticité », retrouvées sur le volume
    #      relu : l'encastrement et la moitié basse de l'alésage.
    # EN — The "Élasticité" page's regions, found again on the volume read
    #      back: the clamp and the lower half of the bore.
    encastrement = pc.mesh.points_on_plane(volume, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    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 — Le modèle de la page « Élasticité » ; le matériau porte en plus
    #      « alpha », que la dilatation lira.
    # EN — The "Élasticité" page's model; the material also carries "alpha",
    #      which the expansion will read.
    fes = pc.FiniteElementSpace(volume)
    elasticite = pc.model.elasticity(fes, "full_3d")
    blocage_x = pc.model.dirichlet(encastrement, elasticite, "u_x")
    blocage_y = pc.model.dirichlet(encastrement, elasticite, "u_y")
    blocage_z = pc.model.dirichlet(encastrement, elasticite, "u_z")
    modele = elasticite | blocage_x | blocage_y | blocage_z
    materiaux = pc.element_field.material_field(
        modele, [("E", E), ("nu", NU), ("alpha", ALPHA)]
    )
    K = pc.matrix.stiffness(modele, materiaux)

    demi_fes = pc.FiniteElementSpace(demi_alesage)
    verticale = pc.model.flux(demi_fes, elasticite, "f_z")
    densite_verticale = pc.element_field.material_field(
        verticale, [("phi_f_z", -PRESSION)]
    )
    force_surfacique = pc.node_field.external_forces(verticale, densite_verticale)

    # FR — La température aux points de Gauss, puis la dilatation
    #      ε_th = α (T − T_ref), la contrainte qu'elle donnerait si rien ne la
    #      gênait, [C] ε_th, et ses forces nodales ∫ [B]ᵀ [C] ε_th dV.
    # EN — The temperature at the Gauss points, then the expansion
    #      ε_th = α (T − T_ref), the stress it would give were nothing in its
    #      way, [C] ε_th, and its nodal forces ∫ [B]ᵀ [C] ε_th dV.
    t_gauss = pc.element_field.interp_to_gauss(temperature, fes)
    eps_th = pc.element_field.thermal_strain(t_gauss, materiaux, fes, T_REF)
    sigma_th = pc.element_field.elastic_stress(modele, eps_th, materiaux)
    force_th = (
        pc.node_field.divergence(sigma_th, "sigma")
        .rename_component("div_sigma_x", "f_x")
        .rename_component("div_sigma_y", "f_y")
        .rename_component("div_sigma_z", "f_z")
    )

    # FR — Les deux chargements partagent les nœuds de l'alésage : la force
    #      surfacique est ramenée sur le support de la dilatation, puis `+`
    #      somme nœud à nœud.
    # EN — Both loads share the bore's nodes: the surface force is brought
    #      onto the expansion's support, then `+` sums node by node.
    second_membre = force_th + pc.node_field.restrict_like(force_surfacique, force_th)

    u = pc.solver.solve(K, second_membre)
    r_x = -pc.node_field.restrict(u, blocage_x.multiplier_mesh()).sum("lambda")
    r_y = -pc.node_field.restrict(u, blocage_y.multiplier_mesh()).sum("lambda")
    r_z = -pc.node_field.restrict(u, blocage_z.multiplier_mesh()).sum("lambda")

    coords = volume.coords()
    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(u * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        volume.plot(
            view=VUE,
            title="Dilatation et force surfacique — déformée ×50, u_x (mm)",
            field=u * 1000.0,
            component="u_x",
            cmap="viridis",
            save=os.path.join(OUT, "mecanique-dilatation-deformee.svg")
            if OUT
            else None,
        )

    # FR — Seule la part de la déformation que la dilatation n'explique pas
    #      crée une contrainte : σ = [C] (ε − ε_th).
    # EN — Only the part of the strain the expansion does not account for
    #      creates a stress: σ = [C] (ε − ε_th).
    deformation = pc.element_field.strain(modele, u, materiaux)
    contrainte = pc.element_field.elastic_stress(
        modele, deformation - eps_th, materiaux
    )
    von_mises_mpa = pc.element_field.von_mises(modele, contrainte) * 1e-6
    volume.plot(
        view=VUE,
        title="Dilatation et force surfacique — von Mises (MPa)",
        field=von_mises_mpa,
        component="von_mises",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-dilatation-von-mises.svg") if OUT else None,
    )

    print(f"relu : {volume.cell_count} HEX20, {len(evolution.frames())} champs")
    print(
        f"température relue : de {temperature.min('T'):.2f} à "
        f"{temperature.max('T'):.2f} °C"
    )
    print(
        f"forces de la dilatation, sommées : {force_th.sum('f_x'):.2e} "
        f"{force_th.sum('f_y'):.2e} {force_th.sum('f_z'):.2e} N"
    )
    print(f"réactions : {r_x:.1f} {r_y:.1f} {r_z:.1f} N")
    print(
        f"u_x de {u.min('u_x') * 1000.0:.3f} à {u.max('u_x') * 1000.0:.3f} mm, "
        f"u_z de {u.min('u_z') * 1000.0:.3f} à {u.max('u_z') * 1000.0:.3f} mm"
    )
    print(
        f"aux points de Gauss : von Mises de {von_mises_mpa.min('von_mises'):.2f} "
        f"à {von_mises_mpa.max('von_mises'):.2f} MPa"
    )


if __name__ == "__main__":
    main()

Suite : Dilatation, matériau hétérogène.

Dilatation, matériau hétérogène

Le calcul de la page Dilatation, matériau homogène, avec un coefficient de dilatation qui varie le long de la pièce. Il est quatre fois plus fort au milieu de la longueur qu’aux bouts, en forme de cloche :

\[ \alpha(x) = \alpha_0 \left( 1 + 3\,e^{-\left( \frac{x - \mu}{s} \right)^2} \right), \qquad \mu = L/2, \qquad s = L/5 \]

C’est l’image d’une pièce dont le matériau n’est pas le même partout, comme une soudure au milieu d’une barre. Rien d’autre ne change : même température, même encastrement, même masse.

Le script complet est tutoriel/mecanique_heterogene.py ; tous les extraits ci-dessous en sont issus directement, dans l’ordre du fichier. Comme le précédent, il relit le fichier qu’écrit tutoriel/thermique_non_lineaire.py.

L’équation résolue

Celle de la page Dilatation, matériau homogène, avec \( \alpha \) qui dépend du point :

\[ \varepsilon_{\text{th}}(x) = \alpha(x)\,(T - T_{\text{ref}})\,I, \qquad \{F_{\text{th}}\} = \int_V [B]^T [C]\,\varepsilon_{\text{th}} \, dV \]

L’intégrale se calcule aux points de Gauss : c’est là qu’il faut connaître \( \alpha \). Le coefficient devient donc un champ par éléments, une valeur par point de Gauss, au lieu d’un nombre.

Les données du calcul

E, NU = 200e9, 0.3  # Pa, — : un acier / a steel
ALPHA_0 = 1e-5  # 1/K : α aux bouts de la pièce / α at the part's ends
MU, SIGMA = LENGTH / 2.0, LENGTH / 5.0  # m : centre et largeur de la cloche
T_REF = 0.0  # °C
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N
PRESSION = POIDS / (math.pi * HOLE_RADIUS * THICKNESS)  # Pa
AMPLIFICATION = 50.0
FICHIER = os.path.join(tempfile.gettempdir(), "thermique.pyr")
TOL = 1e-9  # m

ALPHA_0 est le coefficient aux bouts de la pièce, celui de la page précédente ; MU et SIGMA, le \( \mu \) et le \( s \) de la formule, placent la cloche au milieu de la longueur et lui donnent sa largeur.

Le même début, les mêmes régions

    if not os.path.exists(FICHIER):
        raise SystemExit(
            f"{FICHIER} manque : lancer d'abord tutoriel/thermique_non_lineaire.py"
        )
    relu = pc.load(FICHIER)
    volume = relu["volume"]
    temperature = relu["température (°C)"].frames()[-1]
    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])
    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)
    )

La température relue et les régions de la page précédente, sans changement.

α, un champ aux points de Gauss

    # FR — x aux points de Gauss : les positions des nœuds, interpolées comme
    #      tout champ aux nœuds. La formule s'écrit ensuite sur le champ comme
    #      sur un nombre ; `pc.field.exp` est l'exponentielle d'un champ.
    #      Le résultat s'appelle encore « X » : on le renomme « alpha ».
    # EN — x at the Gauss points: the nodes' positions, interpolated like any
    #      nodal field. The formula is then written on the field as on a
    #      number; `pc.field.exp` is a field's exponential. The result is
    #      still called "X": it is renamed "alpha".
    fes = pc.FiniteElementSpace(volume)
    x = pc.element_field.interp_to_gauss(pc.node_field.positions(volume, ["X"]), fes)
    cloche = pc.field.exp(-(((x - MU) / SIGMA) ** 2))
    alpha = (ALPHA_0 * (1.0 + 3.0 * cloche)).rename_component("X", "alpha")
    volume.plot(
        view=VUE,
        title="Coefficient de dilatation α (10⁻⁶/K)",
        field=alpha * 1e6,
        component="alpha",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-heterogene-alpha.svg") if OUT else None,
    )

pc.node_field.positions donne la coordonnée \( x \) de chaque nœud, un champ aux nœuds comme un autre ; interp_to_gauss la porte aux points de Gauss. La formule s’écrit ensuite sur ce champ comme sur un nombre : -, /, **, * et + agissent point de Gauss par point de Gauss, et pc.field.exp est l’exponentielle d’un champ.

Un calcul sur un champ garde le nom de sa composante. Le résultat s’appelle encore X, le nom de la composante de départ ; rename_component le renomme alpha, le nom que la dilatation cherche dans le champ matériau.

Le script affiche un \( \alpha \) qui va de \( 1{,}0000 \times 10^{-5} \) à \( 3{,}9983 \times 10^{-5} \) /K. Le maximum n’atteint pas tout à fait \( 4\,\alpha_0 \), parce qu’aucun point de Gauss ne tombe exactement au milieu de la longueur.

Le modèle

    # FR — Le champ matériau réunit E et ν, uniformes, et α, champ par
    #      éléments : `|` les juxtapose, chaque composante d'un seul côté.
    # EN — The material field gathers E and ν, uniform, and α, an element
    #      field: `|` lays them side by side, each component on one side only.
    elasticite = pc.model.elasticity(fes, "full_3d")
    blocage_x = pc.model.dirichlet(encastrement, elasticite, "u_x")
    blocage_y = pc.model.dirichlet(encastrement, elasticite, "u_y")
    blocage_z = pc.model.dirichlet(encastrement, elasticite, "u_z")
    modele = elasticite | blocage_x | blocage_y | blocage_z
    materiaux = pc.element_field.material_field(modele, [("E", E), ("nu", NU)]) | alpha
    K = pc.matrix.stiffness(modele, materiaux)

    demi_fes = pc.FiniteElementSpace(demi_alesage)
    verticale = pc.model.flux(demi_fes, elasticite, "f_z")
    densite_verticale = pc.element_field.material_field(
        verticale, [("phi_f_z", -PRESSION)]
    )
    force_surfacique = pc.node_field.external_forces(verticale, densite_verticale)

Le champ matériau est la réunion de deux champs par | : E et nu, uniformes, donnés par material_field, et alpha, le champ par éléments qu’on vient de calculer. Chaque composante n’est que d’un côté, et le résultat est un seul champ matériau à trois composantes.

La dilatation et la résolution

    # FR — Les mêmes lignes qu'à la page précédente : seul α a changé.
    # EN — The same lines as on the previous page: only α has changed.
    t_gauss = pc.element_field.interp_to_gauss(temperature, fes)
    eps_th = pc.element_field.thermal_strain(t_gauss, materiaux, fes, T_REF)
    sigma_th = pc.element_field.elastic_stress(modele, eps_th, materiaux)
    force_th = (
        pc.node_field.divergence(sigma_th, "sigma")
        .rename_component("div_sigma_x", "f_x")
        .rename_component("div_sigma_y", "f_y")
        .rename_component("div_sigma_z", "f_z")
    )
    second_membre = force_th + pc.node_field.restrict_like(force_surfacique, force_th)
    u = pc.solver.solve(K, second_membre)
    r_x = -pc.node_field.restrict(u, blocage_x.multiplier_mesh()).sum("lambda")
    r_y = -pc.node_field.restrict(u, blocage_y.multiplier_mesh()).sum("lambda")
    r_z = -pc.node_field.restrict(u, blocage_z.multiplier_mesh()).sum("lambda")

Mot pour mot les lignes de la page précédente : thermal_strain lit alpha dans le champ matériau, qu’il y soit uniforme ou qu’il varie d’un point de Gauss à l’autre. Les forces de la dilatation se somment encore à zéro, à l’arrondi près, et les réactions encore à 0, 0 et 24 525,0 N.

Résultats

    coords = volume.coords()
    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(u * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        volume.plot(
            view=VUE,
            title="α variable — déformée ×50, u_x (mm)",
            field=u * 1000.0,
            component="u_x",
            cmap="viridis",
            save=os.path.join(OUT, "mecanique-heterogene-deformee.svg")
            if OUT
            else None,
        )
    deformation = pc.element_field.strain(modele, u, materiaux)
    contrainte = pc.element_field.elastic_stress(
        modele, deformation - eps_th, materiaux
    )
    von_mises_mpa = pc.element_field.von_mises(modele, contrainte) * 1e-6
    volume.plot(
        view=VUE,
        title="α variable — von Mises (MPa)",
        field=von_mises_mpa,
        component="von_mises",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-heterogene-von-mises.svg") if OUT else None,
    )

α uniformeα en cloche
allongement du bout libre, \( u_x \) max0,760 mm1,416 mm
point le plus bas, \( u_z \) min−1,280 mm−1,585 mm
von Mises max aux points de Gauss379,19 MPa534,70 MPa

Le milieu de la pièce se dilate jusqu’à quatre fois plus : la pièce s’allonge presque deux fois plus, et se cintre davantage. Le pic de von Mises n’est plus à l’encastrement mais au milieu de la longueur, en haut : la tranche qui veut le plus s’allonger est retenue par ses voisines, qui s’allongent moins. C’est le cas d’une soudure dans une barre chauffée : les contraintes naissent là où le matériau change.

Script complet

"""Tutoriel débutant — 9.7.3 Dilatation, matériau hétérogène. / Beginner
tutorial — 9.7.3 Thermal expansion, heterogeneous material.

FR — Le calcul de la page « Dilatation, matériau homogène », avec un
coefficient de dilatation qui **varie le long de la pièce** : quatre fois plus
fort au milieu de la longueur qu'aux bouts, en cloche,

    α(x) = α₀ (1 + 3 exp(−((x − µ) / σ)²)),   µ = L/2,   σ = L/5.

α est calculé aux points de Gauss, là où la dilatation le lit. La
température est relue, comme à la page précédente, dans le fichier qu'écrit
`tutoriel/thermique_non_lineaire.py`. Le détail pas à pas est dans le livre,
page « Dilatation, matériau hétérogène ».

EN — The "Dilatation, matériau homogène" page's analysis, with an expansion
coefficient that **varies along the part**: four times as strong in the middle
of the length as at the ends, bell-shaped,

    α(x) = α₀ (1 + 3 exp(−((x − µ) / σ)²)),   µ = L/2,   σ = L/5.

α is computed at the Gauss points, where the expansion reads it. The
temperature is read back, as on the previous page, from the file
`tutoriel/thermique_non_lineaire.py` writes. The step-by-step walkthrough
lives in the book's "Dilatation, matériau hétérogène" page.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/thermique_non_lineaire.py   # écrit / writes thermique.pyr
    python tutoriel/mecanique_heterogene.py

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

import math
import os
import tempfile

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

# 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)

# ── Données physiques / Physical data ──────────────────────────────────────
E, NU = 200e9, 0.3  # Pa, — : un acier / a steel
ALPHA_0 = 1e-5  # 1/K : α aux bouts de la pièce / α at the part's ends
MU, SIGMA = LENGTH / 2.0, LENGTH / 5.0  # m : centre et largeur de la cloche
T_REF = 0.0  # °C
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N
PRESSION = POIDS / (math.pi * HOLE_RADIUS * THICKNESS)  # Pa
AMPLIFICATION = 50.0
FICHIER = os.path.join(tempfile.gettempdir(), "thermique.pyr")
TOL = 1e-9  # m


def main() -> None:
    if not os.path.exists(FICHIER):
        raise SystemExit(
            f"{FICHIER} manque : lancer d'abord tutoriel/thermique_non_lineaire.py"
        )
    relu = pc.load(FICHIER)
    volume = relu["volume"]
    temperature = relu["température (°C)"].frames()[-1]
    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])
    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 — x aux points de Gauss : les positions des nœuds, interpolées comme
    #      tout champ aux nœuds. La formule s'écrit ensuite sur le champ comme
    #      sur un nombre ; `pc.field.exp` est l'exponentielle d'un champ.
    #      Le résultat s'appelle encore « X » : on le renomme « alpha ».
    # EN — x at the Gauss points: the nodes' positions, interpolated like any
    #      nodal field. The formula is then written on the field as on a
    #      number; `pc.field.exp` is a field's exponential. The result is
    #      still called "X": it is renamed "alpha".
    fes = pc.FiniteElementSpace(volume)
    x = pc.element_field.interp_to_gauss(pc.node_field.positions(volume, ["X"]), fes)
    cloche = pc.field.exp(-(((x - MU) / SIGMA) ** 2))
    alpha = (ALPHA_0 * (1.0 + 3.0 * cloche)).rename_component("X", "alpha")
    volume.plot(
        view=VUE,
        title="Coefficient de dilatation α (10⁻⁶/K)",
        field=alpha * 1e6,
        component="alpha",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-heterogene-alpha.svg") if OUT else None,
    )

    # FR — Le champ matériau réunit E et ν, uniformes, et α, champ par
    #      éléments : `|` les juxtapose, chaque composante d'un seul côté.
    # EN — The material field gathers E and ν, uniform, and α, an element
    #      field: `|` lays them side by side, each component on one side only.
    elasticite = pc.model.elasticity(fes, "full_3d")
    blocage_x = pc.model.dirichlet(encastrement, elasticite, "u_x")
    blocage_y = pc.model.dirichlet(encastrement, elasticite, "u_y")
    blocage_z = pc.model.dirichlet(encastrement, elasticite, "u_z")
    modele = elasticite | blocage_x | blocage_y | blocage_z
    materiaux = pc.element_field.material_field(modele, [("E", E), ("nu", NU)]) | alpha
    K = pc.matrix.stiffness(modele, materiaux)

    demi_fes = pc.FiniteElementSpace(demi_alesage)
    verticale = pc.model.flux(demi_fes, elasticite, "f_z")
    densite_verticale = pc.element_field.material_field(
        verticale, [("phi_f_z", -PRESSION)]
    )
    force_surfacique = pc.node_field.external_forces(verticale, densite_verticale)

    # FR — Les mêmes lignes qu'à la page précédente : seul α a changé.
    # EN — The same lines as on the previous page: only α has changed.
    t_gauss = pc.element_field.interp_to_gauss(temperature, fes)
    eps_th = pc.element_field.thermal_strain(t_gauss, materiaux, fes, T_REF)
    sigma_th = pc.element_field.elastic_stress(modele, eps_th, materiaux)
    force_th = (
        pc.node_field.divergence(sigma_th, "sigma")
        .rename_component("div_sigma_x", "f_x")
        .rename_component("div_sigma_y", "f_y")
        .rename_component("div_sigma_z", "f_z")
    )
    second_membre = force_th + pc.node_field.restrict_like(force_surfacique, force_th)
    u = pc.solver.solve(K, second_membre)
    r_x = -pc.node_field.restrict(u, blocage_x.multiplier_mesh()).sum("lambda")
    r_y = -pc.node_field.restrict(u, blocage_y.multiplier_mesh()).sum("lambda")
    r_z = -pc.node_field.restrict(u, blocage_z.multiplier_mesh()).sum("lambda")

    coords = volume.coords()
    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(u * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        volume.plot(
            view=VUE,
            title="α variable — déformée ×50, u_x (mm)",
            field=u * 1000.0,
            component="u_x",
            cmap="viridis",
            save=os.path.join(OUT, "mecanique-heterogene-deformee.svg")
            if OUT
            else None,
        )
    deformation = pc.element_field.strain(modele, u, materiaux)
    contrainte = pc.element_field.elastic_stress(
        modele, deformation - eps_th, materiaux
    )
    von_mises_mpa = pc.element_field.von_mises(modele, contrainte) * 1e-6
    volume.plot(
        view=VUE,
        title="α variable — von Mises (MPa)",
        field=von_mises_mpa,
        component="von_mises",
        cmap="viridis",
        save=os.path.join(OUT, "mecanique-heterogene-von-mises.svg") if OUT else None,
    )

    print(
        f"α aux points de Gauss : de {alpha.min('alpha'):.4e} "
        f"à {alpha.max('alpha'):.4e} 1/K"
    )
    print(
        f"forces de la dilatation, sommées : {force_th.sum('f_x'):.2e} "
        f"{force_th.sum('f_y'):.2e} {force_th.sum('f_z'):.2e} N"
    )
    print(f"réactions : {r_x:.1f} {r_y:.1f} {r_z:.1f} N")
    print(
        f"u_x de {u.min('u_x') * 1000.0:.3f} à {u.max('u_x') * 1000.0:.3f} mm, "
        f"u_z de {u.min('u_z') * 1000.0:.3f} à {u.max('u_z') * 1000.0:.3f} mm"
    )
    print(
        f"aux points de Gauss : von Mises de {von_mises_mpa.min('von_mises'):.2f} "
        f"à {von_mises_mpa.max('von_mises'):.2f} MPa"
    )


if __name__ == "__main__":
    main()

Suite : Mécanique non linéaire.

Mécanique non linéaire

Les pages de Mécanique linéaire ont laissé la chape dans un état que son acier ne supporterait pas : avec la dilatation, von Mises y dépasse sa limite d’élasticité de 250 MPa. Les deux pages de ce chapitre lèvent l’une puis l’autre des hypothèses qui rendaient le calcul linéaire :

  • la page Plasticité donne à l’acier une loi qui cesse d’être proportionnelle au-delà de sa limite ;
  • la page Contact remplace la masse par un appui posé dans l’alésage, qui ne fait que s’appuyer : le contact ne tire jamais.

Cette page pose ce que les deux ont en commun : l’équilibre, devenu non linéaire, et la façon dont pyrucast le résout, pas à pas.

Ce qui n’est plus linéaire

La forme discrétisée de l’équilibre reste celle de la page Mécanique linéaire, mais les efforts intérieurs ne s’écrivent plus \( [K]\{U\} \) :

\[ \{R(U)\} = \{F_{\text{ext}}\} - \{F_{\text{int}}(U)\} = 0, \qquad \{F_{\text{int}}(U)\} = \int_V [B]^T \sigma(\varepsilon(U)) \, dV \]

\( \{R\} \) est le résidu : ce qui manque à l’équilibre. Il est nul à la solution.

  • En plasticité, la contrainte n’est plus \( [C]\,\varepsilon \) : elle dépend de toute l’histoire de la déformation. Une fois qu’un point a plastifié, il garde une déformation permanente.
  • Au contact, ce sont les conditions aux limites qui changent : une partie de l’alésage touche l’appui, le reste non, et la zone touchée dépend de la solution elle-même.

Le temps fictif

Une loi plastique dépend du chemin suivi : appliquer toute la charge d’un coup ne donnerait pas le même état que la monter peu à peu. Les chargements montent donc en pas, le long d’un temps qui ne sert qu’à les ordonner. Ce temps fictif n’a rien à voir avec une durée : aucune vitesse n’intervient. Chaque pas part de l’état atteint au pas précédent.

Les itérations d’équilibre

À chaque pas, pyrucast cherche le déplacement qui annule le résidu, par corrections successives :

pour chaque pas
  appliquer les chargements de l'instant visé
  tant que l'équilibre n'est pas atteint
    résoudre  [K] {δU} = {R}        avec la rigidité élastique
    {U} ← {U} + {δU}
    intégrer la loi en chaque point de Gauss : σ, variables internes
    {R} ← {F_ext} − ∫ [B]ᵀ σ dV
  fin
fin

La rigidité \( [K] \) reste celle du matériau élastique : elle est factorisée une fois, et chaque correction ne coûte qu’une résolution. Elle ne suit pas la loi plastique, si bien qu’il faut plusieurs corrections là où la matière plastifie. pyrucast les accélère en combinant les dernières corrections entre elles.

Les blocages passent par leurs multiplicateurs, comme en linéaire ; c’est le système de la page Mécanique linéaire, avec le résidu au second membre :

\[ \begin{bmatrix} K & A^T \\ A & 0 \end{bmatrix} \begin{Bmatrix} \delta U \\ \lambda \end{Bmatrix} = \begin{Bmatrix} R \\ U_{\text{imp}} - A\,U \end{Bmatrix} \]

Un équilibre itéré s’arrête sur une précision. Le pas est atteint quand le résidu, rapporté aux forces appliquées, passe sous precision, et que la déformation ne bouge presque plus d’une correction à la suivante, sous strain_precision. Les deux valent \( 10^{-4} \) par défaut.

Le contact, une inégalité

Le contact n’impose pas une égalité mais une inégalité : en chaque point de l’appui, le jeu avec l’alésage reste positif ou nul, et l’effort de contact ne peut que pousser. L’un des deux est nul : un point qui touche a un jeu nul, un point décollé n’a pas d’effort. pyrucast résout cette alternative en essayant des statuts : il suppose quels points touchent, résout, puis corrige les points dont le jeu ou l’effort a le mauvais signe, jusqu’à ce que tout soit cohérent. La page Contact en donne le détail.

Le parcours

  1. Plasticité : la chape de la page Dilatation, matériau hétérogène, chauffée et chargée en 20 pas, avec un acier plastique parfait. La limite d’élasticité est d’abord constante, puis elle baisse avec la température.
  2. Contact : un appui 1 000 fois plus raide que la chape, posé dans l’alésage et chargé du poids, en 20 pas.

Suite : Plasticité.

Plasticité

La chape de la page Dilatation, matériau hétérogène (encastrée, chargée par la force surfacique, chauffée, avec un coefficient de dilatation en cloche) montait à 534,70 MPa en élasticité, plus du double de la limite de son acier. Ce calcul lui donne une loi plastique parfaite : la contrainte de von Mises ne dépasse jamais la limite d’élasticité \( \sigma_y \), et la matière se déforme de façon permanente là où elle l’atteint. La force et la température montent ensemble, en 20 pas. Deux calculs se suivent :

  1. \( \sigma_y = 250 \) MPa, la même à toute température ;
  2. \( \sigma_y \) qui baisse avec la température, lue dans une table.

Le script complet est tutoriel/plasticite.py ; tous les extraits ci-dessous en sont issus directement, dans l’ordre du fichier. Il relit le fichier qu’écrit tutoriel/thermique_non_lineaire.py.

L’équation résolue

À chaque pas, l’équilibre non linéaire de la page Mécanique non linéaire,

\[ \{F_{\text{ext}}(t)\} - \int_V [B]^T \sigma \, dV = 0, \qquad \{F_{\text{ext}}(t)\} = \frac{t}{100}\,\{F_s\} \]

avec la force surfacique \( \{F_s\} \) de la page Élasticité, et une température qui va de celle du premier instant thermique, 0 °C, à celle du dernier :

\[ T(t) = T_{\text{début}} + \frac{t}{100} \left( T_{\text{fin}} - T_{\text{début}} \right) \]

La contrainte suit la loi plastique parfaite de von Mises. On sépare la déformation en trois parts : la dilatation, une part élastique, et une part plastique \( \varepsilon_p \), permanente :

\[ \sigma = [C] \left( \varepsilon - \varepsilon_{\text{th}} - \varepsilon_p \right), \qquad \sigma_{\text{VM}} \le \sigma_y \]

Tant que \( \sigma_{\text{VM}} < \sigma_y \), \( \varepsilon_p \) ne change pas : c’est l’élasticité. Quand \( \sigma_{\text{VM}} \) atteint \( \sigma_y \), la matière s’écoule : \( \varepsilon_p \) grandit, et la contrainte reste sur la limite. La déformation plastique cumulée \( p \) compte tout l’écoulement subi par un point. Elle ne fait que croître, et vaut 0 là où la matière n’a jamais plastifié.

Les données du calcul

E, NU = 200e9, 0.3  # Pa, —
SIGMA_Y = 250e6  # Pa : limite d'élasticité à froid / yield stress when cold
ALPHA_0 = 1e-5  # 1/K
MU, SIGMA = LENGTH / 2.0, LENGTH / 5.0  # m : la cloche de α / α's bell
T_REF = 0.0  # °C
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N
PRESSION = POIDS / (math.pi * HOLE_RADIUS * THICKNESS)  # Pa

# FR — Un temps fictif : il ne sert qu'à faire monter les chargements.
# EN — A fictitious time: it only drives the loads up.
T_FINAL, N_PAS = 100.0, 20

# FR — σy / σy à froid, selon la température (°C).
# EN — σy / cold σy, against temperature (°C).
TABLE_SIGMA_Y = [
    (0.0, 1.0),
    (100.0, 0.99),
    (150.0, 0.98),
    (200.0, 0.94),
    (215.0, 0.9),
    (230.0, 0.8),
    (235.0, 0.4),
    (240.0, 0.33),
    (250.0, 0.28),
    (275.0, 0.23),
    (300.0, 0.20),
]
AMPLIFICATION = 50.0
FICHIER = os.path.join(tempfile.gettempdir(), "thermique.pyr")
TOL = 1e-9  # m

Les données de la page Dilatation, matériau hétérogène, plus :

  • SIGMA_Y, la limite d’élasticité à froid, 250 MPa ;
  • T_FINAL et N_PAS, le temps fictif de 0 à 100, en 20 pas de 5 ;
  • TABLE_SIGMA_Y, la limite d’élasticité rapportée à sa valeur à froid, en 11 points de 0 à 300 °C. L’acier perd peu jusqu’à 200 °C, puis s’effondre entre 230 et 240 °C.

La température, les régions, le modèle

    # FR — Le volume et les températures du premier et du dernier instant.
    # EN — The volume and the temperatures of the first and the last instant.
    if not os.path.exists(FICHIER):
        raise SystemExit(
            f"{FICHIER} manque : lancer d'abord tutoriel/thermique_non_lineaire.py"
        )
    relu = pc.load(FICHIER)
    volume = relu["volume"]
    t_debut = relu["température (°C)"].frames()[0]
    t_fin = relu["température (°C)"].frames()[-1]
    peau = pc.mesh.consolidate(pc.mesh.skin(volume))

Le premier et le dernier champ de l’évolution relue : la pièce à 0 °C, puis la pièce au dernier instant thermique.

    encastrement = pc.mesh.points_on_plane(volume, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
    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 — La plasticité parfaite en 3D : mêmes inconnues que l'élasticité,
    #      et une variable interne, p, la déformation plastique cumulée.
    # EN — 3-D perfect plasticity: the same unknowns as elasticity, and an
    #      internal variable, p, the cumulated plastic strain.
    fes = pc.FiniteElementSpace(volume)
    plastique = pc.model.plasticity_perfect(fes, "full_3d")
    modele = (
        plastique
        | pc.model.dirichlet(encastrement, plastique, "u_x")
        | pc.model.dirichlet(encastrement, plastique, "u_y")
        | pc.model.dirichlet(encastrement, plastique, "u_z")
    )

    # FR — α en cloche aux points de Gauss, comme à la page précédente ; le
    #      matériau porte en plus « sigma_y ».
    # EN — Bell-shaped α at the Gauss points, as on the previous page; the
    #      material also carries "sigma_y".
    x = pc.element_field.interp_to_gauss(pc.node_field.positions(volume, ["X"]), fes)
    cloche = pc.field.exp(-(((x - MU) / SIGMA) ** 2))
    alpha = (ALPHA_0 * (1.0 + 3.0 * cloche)).rename_component("X", "alpha")
    materiaux = (
        pc.element_field.material_field(
            modele, [("E", E), ("nu", NU), ("sigma_y", SIGMA_Y)]
        )
        | alpha
    )

pc.model.plasticity_perfect(fes, "full_3d") remplace l’élasticité. Ses inconnues sont les mêmes, u_x, u_y, u_z. Il porte en plus, en chaque point de Gauss, une variable interne, p. L’encastrement s’écrit comme avant, et le coefficient de dilatation en cloche aussi. Le champ matériau porte en plus sigma_y.

Des chargements qui évoluent

    # FR — La force surfacique de la page « Élasticité », puis deux
    #      évolutions sur le temps fictif : la force passe de 0 à 1 fois
    #      elle-même, la température du premier au dernier instant thermique.
    # EN — The "Élasticité" page's surface force, then two evolutions over
    #      the fictitious time: the force goes from 0 to 1 times itself, the
    #      temperature from the first to the last thermal instant.
    demi_fes = pc.FiniteElementSpace(demi_alesage)
    verticale = pc.model.flux(demi_fes, plastique, "f_z")
    densite_verticale = pc.element_field.material_field(
        verticale, [("phi_f_z", -PRESSION)]
    )
    force = pc.node_field.external_forces(verticale, densite_verticale)
    rampe_force = pc.Evolution([(0.0, force * 0.0), (T_FINAL, force)])
    rampe_temperature = pc.Evolution([(0.0, t_debut), (T_FINAL, t_fin)])

    instants = []
    for i in range(N_PAS + 1):
        instants.append(i * T_FINAL / N_PAS)

Un chargement qui change au cours du calcul est une évolution : une suite de couples (instant, champ), interpolée linéairement entre eux. Deux couples suffisent ici : la force nulle à \( t = 0 \), la force entière à \( t = 100 \) ; la température du début à \( t = 0 \), celle de la fin à \( t = 100 \). instants liste les 21 instants où l’on veut un résultat : 0, 5, …, 100.

Premier calcul : σy constante

    # FR — 1) σy constante. `step_by_step` remplit `resultats["results"]` :
    #      un état par instant demandé.
    # EN — 1) Constant σy. `step_by_step` fills `resultats["results"]`: one
    #      state per requested instant.
    sigma_y_constante = {
        "model": modele,
        "times": instants,
        "materials": materiaux,
        "loads": rampe_force,
        "temperature": rampe_temperature,
        "t_ref": T_REF,
    }
    pc.stepping.step_by_step(sigma_y_constante)

pc.stepping.step_by_step reçoit un dictionnaire : le modèle, les instants, les matériaux, les chargements, la température et sa référence. Il déroule les pas et range dans resultats["results"] un état par instant : le temps, le déplacement, les contraintes et variables internes ("behaviour"), les réactions et le nombre d’itérations.

Un dictionnaire porte les données et les résultats. Le second calcul reprend celui-ci en ne changeant qu’une clef ; les résultats s’y ajoutent sous "results".

Une limite d’élasticité qui dépend de la température

    # FR — 2) σy selon la température : une évolution, T en abscisse,
    #      « sigma_y » en ordonnée ; hors de la table, la valeur du bord.
    # EN — 2) σy against temperature: an evolution, T as abscissa,
    #      "sigma_y" as ordinate; outside the table, the edge value.
    points = []
    for temperature, rapport in TABLE_SIGMA_Y:
        points.append((temperature, rapport * SIGMA_Y * 1e-6))
    pc.SubEvolution(points).plot(
        x_label="T (°C)",
        y_label="σy (MPa)",
        title="Limite d'élasticité selon la température",
        save=os.path.join(OUT, "plasticite-sigma-y.svg") if OUT else None,
    )
    sigma_y_de_t = []
    for temperature, rapport in TABLE_SIGMA_Y:
        sigma_y_de_t.append((temperature, rapport * SIGMA_Y))
    lois = pc.Evolution(
        sigma_y_de_t,
        abscissa_type="T",
        ordinate_type="sigma_y",
        out_of_range="clamp",
    )

La table, d’abord tracée en MPa :

Puis rangée dans une évolution d’un autre genre : en abscisse, la température "T" ; en ordonnée, la valeur "sigma_y". Entre deux points de la table, la valeur est interpolée ; au-delà, out_of_range="clamp" garde celle du bord.

    # FR — Le même calcul, avec la table : `material_laws` remplace
    #      « sigma_y » à chaque point de Gauss, selon sa température.
    # EN — The same analysis, with the table: `material_laws` replaces
    #      "sigma_y" at each Gauss point, from its temperature.
    sigma_y_variable = dict(sigma_y_constante, material_laws=lois)
    pc.stepping.step_by_step(sigma_y_variable)

dict(sigma_y_constante, material_laws=lois) copie le dictionnaire du premier calcul et y ajoute une clef. material_laws dit au pas à pas de remplacer, à chaque pas et en chaque point de Gauss, sigma_y par la valeur de la table à la température du point.

Ce que les calculs donnent

Le script affiche, pour chaque calcul, les itérations de chaque pas et l’état du dernier :

σy constanteσy selon T
itérations par pas2 jusqu’à \( t = 45 \), puis de 4 à 82 jusqu’à \( t = 45 \), puis de 4 à 18
itérations en tout8398
\( p \) max au dernier pas\( 2{,}8321 \times 10^{-3} \)\( 3{,}2316 \times 10^{-3} \)
von Mises max au dernier pas250,00 MPa249,42 MPa
point le plus bas−1,672 mm−1,759 mm
réaction verticale24 525,0 N24 525,0 N

La première plastification arrive à \( t = 50 \) dans le premier calcul. C’est ce qu’annonçait l’élasticité : avec des chargements proportionnels à \( t \), les 534,70 MPa du calcul élastique atteignent 250 MPa un peu avant la moitié du chemin. Jusque-là, deux itérations suffisent par pas ; ensuite il en faut davantage, à mesure que la zone plastique s’étend.

von Mises ne dépasse pas σy aux points de Gauss. Dans le premier calcul, le maximum vaut exactement 250,00 MPa : là où la matière s’écoule, la contrainte reste sur la limite. Dans le second, chaque point bute sur sa propre limite, plus basse là où il fait chaud. Le maximum, 249,42 MPa, est la limite d’un point resté froid, une vingtaine de degrés, près de l’encastrement. Ailleurs, la limite a baissé : la chape plastifie davantage et descend plus bas. La réaction, elle, est toujours le poids.

Les champs, pas après pas

    # FR — p à chaque instant du second calcul, en millièmes, rangé dans une
    #      évolution et tracé d'un seul appel : dans la fenêtre, un curseur
    #      parcourt les pas. L'image enregistrée est celle du dernier pas.
    # EN — p at each instant of the second analysis, in thousandths, stored
    #      in an evolution and plotted in a single call: in the window, a
    #      slider runs through the steps. The saved image is the last step's.
    champs_p = []
    for resultat in sigma_y_variable["results"]:
        champs_p.append((resultat["time"], resultat["behaviour"] * 1000.0))
    evolution_p = pc.Evolution(champs_p)
    evolution_p.plot(
        mesh=volume,
        view=VUE,
        component="p",
        x_label="temps fictif",
        title="σy selon T — déformation plastique cumulée p (10⁻³)",
        frame=N_PAS,
        save=os.path.join(OUT, "plasticite-p.svg") if OUT else None,
    )

    # FR — von Mises au dernier pas du premier calcul, en MPa.
    # EN — von Mises at the first analysis's last step, in MPa.
    dernier_1 = sigma_y_constante["results"][-1]
    von_mises_1 = pc.element_field.von_mises(modele, dernier_1["behaviour"]) * 1e-6
    volume.plot(
        view=VUE,
        title="σy constante — von Mises au dernier pas (MPa)",
        field=von_mises_1,
        component="von_mises",
        cmap="viridis",
        save=os.path.join(OUT, "plasticite-von-mises.svg") if OUT else None,
    )

Les 21 états de \( p \) du second calcul, rangés dans une évolution, se tracent d’un seul appel, comme les températures de la page Thermique non linéaire : dans la fenêtre, un curseur parcourt les pas. L’image ci-dessous est le dernier.

La matière plastifie surtout à deux endroits. En haut, au milieu de la longueur, c’est là que la dilatation en cloche concentrait les contraintes. Autour de l’alésage, c’est la zone la plus chaude, où la limite d’élasticité a baissé.

La carte de von Mises du premier calcul montre, en haut au milieu, la zone où la contrainte bute sur 250 MPa. Sa légende monte pourtant au-dessus de 250 MPa : comme à la page Élasticité, la couleur d’un élément vient d’un ajustement de ses points de Gauss, lu jusqu’à ses nœuds, et cet ajustement déborde un peu. Aux points de Gauss, là où la loi est intégrée, la limite est tenue.

La déformée

    dernier_2 = sigma_y_variable["results"][-1]
    coords = volume.coords()
    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(dernier_2["displacement"] * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        volume.plot(
            view=VUE,
            title="σy selon T — déformée ×50 au dernier pas, p (10⁻³)",
            field=dernier_2["behaviour"] * 1000.0,
            component="p",
            cmap="viridis",
            save=os.path.join(OUT, "plasticite-deformee.svg") if OUT else None,
        )

La déformée du second calcul, colorée par \( p \). La flexion de la masse et le cintrage de la dilatation s’ajoutent, comme en élasticité ; la plastification les accentue.

Script complet

"""Tutoriel débutant — 9.8.1 Plasticité. / Beginner tutorial — 9.8.1 Plasticity.

FR — La chape de la page « Dilatation, matériau hétérogène » (encastrée,
chargée par la force surfacique, chauffée, α en cloche) dépassait la limite
d'élasticité de son acier. Ce script lui donne une loi **plastique
parfaite** : au-delà de σy, la contrainte n'augmente plus, la matière se
déforme de façon permanente. La force et la température montent ensemble, en
20 pas, sur un temps fictif de 0 à 100. Deux calculs :

1. σy = 250 MPa, la même à toute température ;
2. σy qui baisse avec la température, lue dans une table.

La température est relue dans le fichier qu'écrit
`tutoriel/thermique_non_lineaire.py`. Le détail pas à pas est dans le livre,
page « Plasticité ».

EN — The lug of the "Dilatation, matériau hétérogène" page (clamped, loaded
by the surface force, heated, bell-shaped α) went past its steel's yield
stress. This script gives it a **perfectly plastic** law: beyond σy the
stress no longer rises, and the material deforms permanently. Force and
temperature rise together, in 20 steps, over a fictitious time from 0 to
100. Two analyses:

1. σy = 250 MPa, the same at every temperature;
2. σy dropping with temperature, read from a table.

The temperature is read back from the file `tutoriel/thermique_non_lineaire.py`
writes. The step-by-step walkthrough lives in the book's "Plasticité" page.

Lancement / Running ::

    pip install pyrucast
    python tutoriel/thermique_non_lineaire.py   # écrit / writes thermique.pyr
    python tutoriel/plasticite.py

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

import math
import os
import tempfile

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

# 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)

# ── Données physiques / Physical data ──────────────────────────────────────
E, NU = 200e9, 0.3  # Pa, —
SIGMA_Y = 250e6  # Pa : limite d'élasticité à froid / yield stress when cold
ALPHA_0 = 1e-5  # 1/K
MU, SIGMA = LENGTH / 2.0, LENGTH / 5.0  # m : la cloche de α / α's bell
T_REF = 0.0  # °C
MASSE, G = 2500.0, 9.81  # kg, m/s²
POIDS = MASSE * G  # N
PRESSION = POIDS / (math.pi * HOLE_RADIUS * THICKNESS)  # Pa

# FR — Un temps fictif : il ne sert qu'à faire monter les chargements.
# EN — A fictitious time: it only drives the loads up.
T_FINAL, N_PAS = 100.0, 20

# FR — σy / σy à froid, selon la température (°C).
# EN — σy / cold σy, against temperature (°C).
TABLE_SIGMA_Y = [
    (0.0, 1.0),
    (100.0, 0.99),
    (150.0, 0.98),
    (200.0, 0.94),
    (215.0, 0.9),
    (230.0, 0.8),
    (235.0, 0.4),
    (240.0, 0.33),
    (250.0, 0.28),
    (275.0, 0.23),
    (300.0, 0.20),
]
AMPLIFICATION = 50.0
FICHIER = os.path.join(tempfile.gettempdir(), "thermique.pyr")
TOL = 1e-9  # m


def main() -> None:
    # FR — Le volume et les températures du premier et du dernier instant.
    # EN — The volume and the temperatures of the first and the last instant.
    if not os.path.exists(FICHIER):
        raise SystemExit(
            f"{FICHIER} manque : lancer d'abord tutoriel/thermique_non_lineaire.py"
        )
    relu = pc.load(FICHIER)
    volume = relu["volume"]
    t_debut = relu["température (°C)"].frames()[0]
    t_fin = relu["température (°C)"].frames()[-1]
    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])
    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 — La plasticité parfaite en 3D : mêmes inconnues que l'élasticité,
    #      et une variable interne, p, la déformation plastique cumulée.
    # EN — 3-D perfect plasticity: the same unknowns as elasticity, and an
    #      internal variable, p, the cumulated plastic strain.
    fes = pc.FiniteElementSpace(volume)
    plastique = pc.model.plasticity_perfect(fes, "full_3d")
    modele = (
        plastique
        | pc.model.dirichlet(encastrement, plastique, "u_x")
        | pc.model.dirichlet(encastrement, plastique, "u_y")
        | pc.model.dirichlet(encastrement, plastique, "u_z")
    )

    # FR — α en cloche aux points de Gauss, comme à la page précédente ; le
    #      matériau porte en plus « sigma_y ».
    # EN — Bell-shaped α at the Gauss points, as on the previous page; the
    #      material also carries "sigma_y".
    x = pc.element_field.interp_to_gauss(pc.node_field.positions(volume, ["X"]), fes)
    cloche = pc.field.exp(-(((x - MU) / SIGMA) ** 2))
    alpha = (ALPHA_0 * (1.0 + 3.0 * cloche)).rename_component("X", "alpha")
    materiaux = (
        pc.element_field.material_field(
            modele, [("E", E), ("nu", NU), ("sigma_y", SIGMA_Y)]
        )
        | alpha
    )

    # FR — La force surfacique de la page « Élasticité », puis deux
    #      évolutions sur le temps fictif : la force passe de 0 à 1 fois
    #      elle-même, la température du premier au dernier instant thermique.
    # EN — The "Élasticité" page's surface force, then two evolutions over
    #      the fictitious time: the force goes from 0 to 1 times itself, the
    #      temperature from the first to the last thermal instant.
    demi_fes = pc.FiniteElementSpace(demi_alesage)
    verticale = pc.model.flux(demi_fes, plastique, "f_z")
    densite_verticale = pc.element_field.material_field(
        verticale, [("phi_f_z", -PRESSION)]
    )
    force = pc.node_field.external_forces(verticale, densite_verticale)
    rampe_force = pc.Evolution([(0.0, force * 0.0), (T_FINAL, force)])
    rampe_temperature = pc.Evolution([(0.0, t_debut), (T_FINAL, t_fin)])

    instants = []
    for i in range(N_PAS + 1):
        instants.append(i * T_FINAL / N_PAS)

    # FR — 1) σy constante. `step_by_step` remplit `resultats["results"]` :
    #      un état par instant demandé.
    # EN — 1) Constant σy. `step_by_step` fills `resultats["results"]`: one
    #      state per requested instant.
    sigma_y_constante = {
        "model": modele,
        "times": instants,
        "materials": materiaux,
        "loads": rampe_force,
        "temperature": rampe_temperature,
        "t_ref": T_REF,
    }
    pc.stepping.step_by_step(sigma_y_constante)

    # FR — 2) σy selon la température : une évolution, T en abscisse,
    #      « sigma_y » en ordonnée ; hors de la table, la valeur du bord.
    # EN — 2) σy against temperature: an evolution, T as abscissa,
    #      "sigma_y" as ordinate; outside the table, the edge value.
    points = []
    for temperature, rapport in TABLE_SIGMA_Y:
        points.append((temperature, rapport * SIGMA_Y * 1e-6))
    pc.SubEvolution(points).plot(
        x_label="T (°C)",
        y_label="σy (MPa)",
        title="Limite d'élasticité selon la température",
        save=os.path.join(OUT, "plasticite-sigma-y.svg") if OUT else None,
    )
    sigma_y_de_t = []
    for temperature, rapport in TABLE_SIGMA_Y:
        sigma_y_de_t.append((temperature, rapport * SIGMA_Y))
    lois = pc.Evolution(
        sigma_y_de_t,
        abscissa_type="T",
        ordinate_type="sigma_y",
        out_of_range="clamp",
    )

    # FR — Le même calcul, avec la table : `material_laws` remplace
    #      « sigma_y » à chaque point de Gauss, selon sa température.
    # EN — The same analysis, with the table: `material_laws` replaces
    #      "sigma_y" at each Gauss point, from its temperature.
    sigma_y_variable = dict(sigma_y_constante, material_laws=lois)
    pc.stepping.step_by_step(sigma_y_variable)

    # FR — p à chaque instant du second calcul, en millièmes, rangé dans une
    #      évolution et tracé d'un seul appel : dans la fenêtre, un curseur
    #      parcourt les pas. L'image enregistrée est celle du dernier pas.
    # EN — p at each instant of the second analysis, in thousandths, stored
    #      in an evolution and plotted in a single call: in the window, a
    #      slider runs through the steps. The saved image is the last step's.
    champs_p = []
    for resultat in sigma_y_variable["results"]:
        champs_p.append((resultat["time"], resultat["behaviour"] * 1000.0))
    evolution_p = pc.Evolution(champs_p)
    evolution_p.plot(
        mesh=volume,
        view=VUE,
        component="p",
        x_label="temps fictif",
        title="σy selon T — déformation plastique cumulée p (10⁻³)",
        frame=N_PAS,
        save=os.path.join(OUT, "plasticite-p.svg") if OUT else None,
    )

    # FR — von Mises au dernier pas du premier calcul, en MPa.
    # EN — von Mises at the first analysis's last step, in MPa.
    dernier_1 = sigma_y_constante["results"][-1]
    von_mises_1 = pc.element_field.von_mises(modele, dernier_1["behaviour"]) * 1e-6
    volume.plot(
        view=VUE,
        title="σy constante — von Mises au dernier pas (MPa)",
        field=von_mises_1,
        component="von_mises",
        cmap="viridis",
        save=os.path.join(OUT, "plasticite-von-mises.svg") if OUT else None,
    )

    dernier_2 = sigma_y_variable["results"][-1]
    coords = volume.coords()
    deformee = coords.add_config("déformée ×50")
    pc.coords.displace_from(dernier_2["displacement"] * AMPLIFICATION, 0, deformee)
    with coords.in_config(deformee):
        volume.plot(
            view=VUE,
            title="σy selon T — déformée ×50 au dernier pas, p (10⁻³)",
            field=dernier_2["behaviour"] * 1000.0,
            component="p",
            cmap="viridis",
            save=os.path.join(OUT, "plasticite-deformee.svg") if OUT else None,
        )

    for nom, calcul in (
        ("σy constante", sigma_y_constante),
        ("σy selon T", sigma_y_variable),
    ):
        iterations = []
        for resultat in calcul["results"]:
            iterations.append(resultat["iterations"])
        dernier = calcul["results"][-1]
        von_mises = pc.element_field.von_mises(modele, dernier["behaviour"]) * 1e-6
        print(f"{nom} : itérations par pas {iterations[1:]}, {sum(iterations)} en tout")
        print(
            f"{nom}, dernier pas : p max {dernier['behaviour'].max('p'):.4e}, "
            f"von Mises max {von_mises.max('von_mises'):.2f} MPa, "
            f"u_z min {dernier['displacement'].min('u_z') * 1000.0:.3f} mm, "
            f"réaction f_z {dernier['reactions'].sum('f_z'):.1f} N"
        )
    premier_pas_plastique = None
    for resultat in sigma_y_constante["results"]:
        if premier_pas_plastique is None and resultat["behaviour"].max("p") > 0.0:
            premier_pas_plastique = resultat["time"]
    print(f"σy constante : premier instant plastifié {premier_pas_plastique}")


if __name__ == "__main__":
    main()

Suite : Contact.

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.

Compléments

Éléments structuraux

pyrucast choisit le type d’élément par l’opérateur de modèle : un opérateur model.<physique>(fes, ...) par famille d’éléments, et des composantes matériau nommées pour la géométrie de la section.

pyrucastprimales / duales
model.truss(fes)u_x,u_y(,u_z) / f_x,f_y(,f_z)
model.timoshenko(fes)selon la dimension : w, theta / f_w, m_theta en 1-D, u_x, u_y, r_z en plan, six DDL dans l’espace

Barre en traction — comparée à la solution analytique \( u_x = F \cdot L / (E \cdot A) \), export du résultat au format VTK :

    coords = pc.Coords(2)
    n0 = coords.add_node([0.0, 0.0])
    n1 = coords.add_node([L, 0.0])
    mesh = pc.mesh.line(n0, n1, 1)
    fes = pc.FiniteElementSpace(mesh)

    modele = pc.model.truss(fes)
    modele = modele | encastrement(modele, n0, "u_x")
    modele = modele | encastrement(modele, n0, "u_y")
    modele = modele | encastrement(modele, n1, "u_y")  # no transverse stiffness

    materiaux = pc.element_field.material_field(modele, [("E", E), ("A", A)])

    charge = pc.mesh.poi1_from_nodes([n1])
    second_membre = pc.NodeField(charge, ["f_x"])
    second_membre[0].set_value(n1, "f_x", F)

    K = pc.matrix.stiffness(modele, materiaux)
    solution = pc.solver.solve(K, second_membre)
    u_propre = pc.node_field.restrict_like(solution, pc.NodeField(mesh, ["u_x", "u_y"]))
    chemin = os.path.join(tempfile.gettempdir(), "barre.vtk")
    pc.export.export_vtk(mesh, chemin, u_propre)
    print(f"Displacement field exported (VTK, readable by ParaView): {chemin}")

Les poutres d’Euler-Bernoulli (model.bernoulli) et les coques (model.shell) ont leurs pages dans le chapitre de mécanique. Un calcul axisymétrique se pose sur un Coords.axisymmetric(), le plan méridien d’un corps de révolution : voir Coordonnées.

Pour la matrice de masse cohérente et la rigidité géométrique (flambage linéarisé), voir Assemblage par MatrixKind — pyrucast.matrix.mass/lump/geometric/tangent.

Mailler en volume

À partir de la page Maillage, les calculs de ce tutoriel se font en volume. Les mailleurs de volume de pyrucast :

  • pyrucast.mesh.extrude(mesh, direction, n_couches) — balayage d’un maillage SEG2/TRI3/QUA4 selon une direction, dans le même espace de coordonnées ;
  • pyrucast.mesh.revolve(mesh, angle, n_couches, centre, axe) — le même balayage, mais en rotation autour d’un axe ; un tour complet referme le volume engendré ;
  • pyrucast.mesh.sweep_solid(mesh_a, mesh_b, n_couches) — balayage entre deux profils TRI3/QUA4 non parallèles ;
  • pyrucast.mesh.triangulate_volume(enveloppe, taille) — remplissage TET4 d’une enveloppe TRI3 fermée par triangulation de Delaunay 3D.

La page Maillage emploie extrude et triangulate_volume, la page Contact revolve ; sweep_solid n’y sert pas.

Éléments finis supportés

Catalogue complet : Éléments finis supportés — 15 types, de SEG2 à HEX27, y compris les versions quadratiques (pyrucast.mesh.to_quadratic passe un maillage linéaire en quadratique).

Échanges avec les outils extérieurs

pyrucast échange avec trois outils externes :

  • gmsh — fichier .msh (pyrucast.mesh.read_gmsh, read_gmsh_str) ou session vivante, dans les deux sens et avec les vues (pyrucast.mesh.from_gmsh, pyrucast.export.to_gmsh) ;
  • MED (Salome, code_aster) au travers de medcoupling — maillages, groupes, champs aux nœuds, aux mailles et aux points de Gauss, séries temporelles (pyrucast.mesh.from_medcoupling, pyrucast.export.to_medcoupling) ;
  • VTK legacy pour ParaView, ASCII ou binaire, et séries temporelles (pyrucast.export.export_vtk) — voir la fin du script de barre plus haut.

Un calcul se sauvegarde et se relit avec pyrucast.save et pyrucast.load : la page Thermique non linéaire écrit un fichier que les pages de mécanique relisent.

Non disponible dans pyrucast. Pas d’échange Nastran/Abaqus, pas de format CSV/Excel dédié pour les listes ou les tables. L’export VTK est en outre limité au format legacy (pas de .vtu) : un champ par fichier.

Développer sur pyrucast

pyrucast est un projet Rust ordinaire. Pour ajouter une physique, un élément fini ou un opérateur, on modifie directement le code Rust puis on relance maturin develop --release. Voir Développer — en particulier Ajouter une physique et Ajouter un élément fini.

Communauté

Projet ouvert : voir le fichier AGENTS.md/README du dépôt pour les modalités de contribution actuelles.

Développer

Cette partie s’adresse aux contributeurs de pyrucast. Elle décrit l’organisation du code, les conventions à respecter, le modèle mémoire sous-jacent, comment documenter, compiler et tester, et les deux extensions les plus courantes (ajouter une physique, ajouter un élément fini).

  • Arborescence — la carte des sources : où vit chaque morceau (containers/, ops/, models/, py/, viz/…).
  • Conventions & philosophie — méthode vs fonction libre, erreurs, sérialisation, Definition of Done.
  • Correspondance Rust ↔ Python — la table complète des structures, des fonctions et des opérateurs, des deux côtés.
  • Documentation et tests — les types de test et ce que chacun prouve, où vit un exemple, la règle « aucune page ne possède de code », et ce qui n’est pas vérifié.
  • Modèle mémoire — ce que Python en voit (partage, durée de vie, caches), puis les objets derrière un Handle, les guards, le compteur par nœud de Coords et la mécanique de l’archive.
  • Parallélisme — rayon porté au-dessus des noyaux, zéro-copie, déterminisme, et ce qui reste séquentiel.
  • Compilation et tests — installation détaillée, features Cargo, génération du stub .pyi, script « tout-en-un », dépannage.
  • Ajouter une physique — le coût en O(1) fichier d’une nouvelle variante de SubModel / SubModelKind.
  • Ajouter un élément fini — un nouveau ElementType, son interpolation et sa quadrature.
  • Interrompre une fonction — le jeton Cancel : un Ctrl+C ou un timeout qui arrête une boucle longue.

Arborescence

La carte des sources : où vit chaque morceau. La règle d’or est la séparation conteneurs / opérateurs / binding — les structures de données (containers/) ne connaissent pas les opérateurs (ops/), et le binding Python (py/) est un miroir 1:1 qui n’ajoute aucune logique.

Deux découpages secondaires en découlent, et l’arborescence les rend visibles : les types indivisibles vivent dans atoms/ (seul un conteneur peut être le sujet d’un opérateur), et chaque module d’ops/ porte le nom du conteneur qu’il produit.

Le dépôt tient deux paquets. À côté de la bibliothèque, macros/ porte les macros procédurales — aujourd’hui #[py_op], qui dérive d’un opérateur libre la méthode de son sujet. Cette séparation n’est pas un choix de rangement : une crate proc-macro ne peut rien exporter d’autre que des macros, et une crate ordinaire ne peut pas en contenir.

macros/
└── src/lib.rs          # `#[py_op]` : receveur déduit du sujet, signature pyo3
                        #   amputée de son entrée de tête, `#[doc]` et
                        #   `#[allow]` recopiés sur la méthode
src/
├── lib.rs              # racine de la crate + #[pymodule] (enregistrement Python)
│
├── handle.rs           # Handle<T> : Arc<RwLock<T>>, guards possédés, identité
├── persist.rs          # trait Portable (serde + bincode), format portable
├── error.rs            # PyrucastError + Result (l'unique type d'erreur)
├── dump.rs             # trait Dump (3ᵉ niveau d'affichage : contenu intégral)
├── aggregate.rs        # trait Aggregate + macros (len/[i]/union, pyméthodes)
├── parallel.rs         # prelude rayon + politique de grain (MIN_PARALLEL_LEN)
├── interrupt.rs        # trait Cancel + jetons (NoCancel, AtomicBool, Deadline)
│
├── coords.rs           # LE MAGASIN : Coords (jeux de coordonnées, refcount par nœud)
│
├── atoms/              # LES INSÉCABLES (jamais sujet d'un opérateur)
│   ├── mod.rs
│   ├── node.rs         # Node (accesseur RAII) + NodeId
│   ├── cell.rs         # Cell (désigne une maille d'un SubMesh)
│   ├── element.rs      # Element (désigne un élément d'un SubFiniteElementSpace)
│   ├── element_type.rs # enum ElementType (stockage, sérialisation, ALL, as_kind)
│   ├── element_kind/   # UN FICHIER PAR ÉLÉMENT + le trait qui les lie
│   │   ├── mod.rs          # trait ElementKind, Facet, as_kind()
│   │   ├── interpolation.rs# enum Interpolation (façade sur ElementKind::degree)
│   │   ├── quadrature.rs   # enum QuadratureRule (façade + briques partagées)
│   │   └── tri3.rs, tri6.rs, hex8.rs, …  # un par type d'élément
│   ├── point.rs        # Point2 / Point3 / Vector2 / Vector3 (nalgebra)
│   ├── band.rs         # Band (bande de valeurs ge/gt/le/lt — mask, select)
│   └── color.rs        # RgbColor (couleur de face, viz)
│
├── containers/         # LES DIVISIBLES (structures de données, aucune dépendance à ops/)
│   ├── mod.rs
│   ├── mesh.rs         # Mesh, SubMesh
│   ├── finite_element_space/
│   │   └── mod.rs          # FiniteElementSpace, SubFiniteElementSpace
│   ├── field.rs        # traits Field / SubField (contrat commun des champs)
│   ├── node_field.rs   # NodeField / SubNodeField
│   ├── element_field.rs# ElementField / SubElementField
│   ├── model.rs        # Model / SubModel (enum de stockage + dispatch)
│   ├── matrix.rs       # Matrix / SubMatrix (matrice creuse COO)
│   └── evolution.rs    # Evolution / SubEvolution (valeur tabulée, interpolée)
│
├── models/             # LES PHYSIQUES (une struct + impl SubModelKind par fichier)
│   ├── mod.rs              # traits SubModelKind / Domain / Constraint, MatrixKind
│   ├── kernel.rs           # LES DRIVERS parallèles au-dessus des noyaux purs
│   ├── heat_conduction.rs
│   ├── boundary_transfer.rs # échange de surface avec une ambiante (Robin / film)
│   ├── transfer.rs         # le noyau h∫NiNj partagé par les deux échanges
│   ├── continuum/          # LA MODÉLISATION continue, partagée par les trois
│   │   │                   #   familles mécaniques — pas une physique
│   │   ├── mod.rs          #     Continuum + les noyaux K, M, Kg, Kt
│   │   ├── voigt.rs        #     nomenclature ε / σ / D_alg, lecture par indice
│   │   ├── elastic.rs      #     l'opérateur élastique (D, λ, μ) — prédicteur
│   │   │                   #     de la plasticité, module sain de l'endommagement
│   │   ├── material.rs     #     MatRead : la ligne matériau lue par position
│   │   └── internal_force.rs #   Bᵀσ (BSIG), aussi appelé sans sous-modèle
│   ├── truss.rs
│   ├── elasticity.rs       # LA physique élastique, pour toute loi sans état
│   ├── elasticity/         #   ElasticLaw (identité) + StatelessLawKind
│   │   └── linear.rs       #     σ = D:ε
│   ├── plasticity.rs       # LA physique élastoplastique, pour toute loi
│   ├── plasticity/         #   la machinerie des lois d'écoulement…
│   │   ├── law.rs          #     PlasticLaw (identité) + ReturnMapLawKind
│   │   └── von_mises.rs, drucker_prager.rs, ottosen.rs, viscous.rs, gurson.rs
│   ├── damage.rs           # LA physique d'endommagement, pour toute loi
│   ├── damage/             #   DamageLaw (identité) + DirectUpdateLawKind,
│   │   │                   #   puis une loi par fichier
│   │   └── mazars.rs, damage_tc.rs, sic_sic.rs
│   ├── timoshenko.rs
│   ├── frame.rs           # portique 2D
│   ├── frame3d.rs         # cadre 3D
│   ├── dirichlet.rs       # contrainte (multiplicateurs de Lagrange)
│   ├── mpc.rs             # relation multi-points
│   ├── embedded.rs        # baignage (nœuds immergés dans un hôte)
│   └── contact.rs         # contact nœud-surface (unilatéral)
│
├── ops/                # LES OPÉRATEURS — un module par conteneur produit
│   ├── mod.rs
│   ├── mesh/           # → Mesh : mailleurs, transformations, select
│   │   ├── triangulation/  # briques 2D Delaunay (ear clipping, CDT, Ruppert)
│   │   ├── tetrahedralization/  # briques 3D Delaunay (prédicats exacts, …)
│   │   ├── paving/         # front avançant 2D (pave_surface)
│   │   └── plaster/        # front avançant 3D (pave_volume)
│   ├── node_field/     # → NodeField : positions, divergence, restrict, flux, …
│   ├── element_field/  # → ElementField : gradient, deformation, material_field
│   │   └── behavior.rs     # intégration de la loi de comportement (COMP)
│   ├── model/          # → Model : les déclarations de physique. Les formes
│   │                   #   courantes sont déclarées dans models/<nom>.rs et
│   │                   #   seulement ré-exportées ici ; les contraintes, le
│   │                   #   transfert d'interface et les deux lois génériques
│   │                   #   gardent leur fichier
│   ├── matrix.rs       # → Matrix : stiffness, mass, geometric, tangent, lump
│   ├── coords.rs       # écrit dans le magasin : set, displace
│   ├── measure/        # → un nombre : integral, xtx, xty
│   ├── geom/           # → une position : locate_points, project_points (internes)
│   ├── field/          # polymorphe champ → même champ : mask, maths élémentaires
│   ├── solver/         # → NodeField (l'exception nommée) : solve, eliminate, unilateral
│   ├── export/         # effets de bord : export VTK (read_gmsh côté mesh)
│   ├── coloring.rs     # machinerie d'assemblage partagée (pas un opérateur)
│   └── scatter.rs      # idem
│
├── py/                 # BINDING PyO3 (miroir 1:1 de containers/ + ops/)
│   ├── mod.rs
│   ├── coords.rs, node.rs, cell.rs, mesh.rs, …   # un wrapper Py<Foo> par objet
│   ├── signals.rs      # jeton PySignals (Ctrl+C via Python::check_signals)
│   └── ops/            # un wrapper par module d'opérateurs
│
├── viz/                # VISUALISATION (feature `viz` / `viz-interactive`)
│   ├── mod.rs, drawable.rs, mesh_draw.rs, camera.rs, field_color.rs,
│   │                     axes.rs, curve.rs, overlay.rs, revolve.rs
│   ├── subdivide.rs    # rendu interpolé (découpe des faces)
│   └── window.rs       # fenêtre interactive (feature `viz-interactive`)
│
└── bin/
    ├── stub_gen.rs     # génère le stub .pyi (feature `stub-gen`)
    └── scaling.rs      # mesure de montée en charge (parallélisme)

Le paquet Python est à côté des sources Rust : le projet est un mixed layout maturin, et l’extension compilée est le sous-module privé _pyrucast, plat. La couche Python pure ne fait que le ranger — c’est elle qui donne à l’API sa forme publique (pyrucast.<module>.<verbe>).

python/pyrucast/
├── __init__.py        # conteneurs + atomes au top-level (Mesh, Coords, …)
├── mesh.py            # un module par module d'ops/ : ré-exporte _pyrucast
├── node_field.py      #   en rendant leur vrai nom aux homonymes
├── element_field.py   #   (`consolidate_mesh as consolidate`, …)
├── matrix.py, model.py, field.py, measure.py, export.py, solver.py, coords.py
├── stepping/          # couche Python pure : le pas à pas (PASAPAS et ses procédures)
├── py.typed
└── _pyrucast/
    └── __init__.pyi   # stub typé, généré par stub_gen, versionné

À la racine du dépôt :

Cargo.toml          # crate (cdylib + rlib), features, dépendances approuvées
pyproject.toml      # côté maturin (python-source = python, module-name)
CONVENTIONS.md      # règles de code (source de la page Conventions)
script/check_all.sh # enchaîne toutes les vérifications (check_*.sh isolément)
tests/              # tests d'intégration Rust (*.rs) + tests Python (python/)
examples/           # scripts Python complets (thermique, treillis, poutres…)
tutoriel/           # scripts du tutoriel débutant
benches/            # bancs criterion (parallel.rs, geom.rs)
book/               # cette documentation (mdbook)

Graphe des dépendances externes

Les crates tierces (Cargo.toml) et, pour chacune, le module où elle est confinée — une des règles du projet est qu’une dépendance ne fuit pas hors de son étage. Les arêtes pleines sont toujours liées ; les arêtes pointillées ne le sont que si la feature correspondante est activée. Le cargo build par défaut est Rust pur : rien de pointillé n’est compilé.

flowchart LR
    pc(["pyrucast"])

    subgraph core["Toujours actives — build Rust pur par défaut"]
        direction TB
        nalgebra["nalgebra<br/><small>primitives — mesh, ops, viz</small>"]
        nsparse["nalgebra-sparse<br/><small>creux CSR/CSC — matrix, assemble</small>"]
        faer["faer<br/><small>LU creux — ops::solver</small>"]
        rayon["rayon<br/><small>parallel/, models::kernel</small>"]
        parking["parking_lot<br/><small>guards owned — handle/</small>"]
        serde["serde<br/><small>Portable — persist/ (+ derive)</small>"]
        bincode["bincode<br/><small>format binaire — persist/</small>"]
        paste["paste<br/><small>macros — aggregate/</small>"]
    end

    subgraph pyfeat["feature python-api / extension-module"]
        pyo3["pyo3<br/><small>binding — py/</small>"]
    end
    subgraph stubfeat["feature stub-gen"]
        stubgen["pyo3-stub-gen<br/><small>_pyrucast/__init__.pyi — bin/, py/</small>"]
    end
    subgraph vizfeat["feature viz / viz-interactive"]
        plotters["plotters<br/><small>rendu PNG/SVG — viz/</small>"]
        winit["winit<br/><small>fenêtre — viz::window</small>"]
        softbuffer["softbuffer<br/><small>framebuffer — viz::window</small>"]
    end
    subgraph devfeat["dev-dependencies"]
        criterion["criterion<br/><small>bench — benches/</small>"]
    end

    pc --> nalgebra & nsparse & faer & rayon & parking & serde & bincode & paste
    nsparse --> nalgebra
    bincode --> serde

    pc -. "python-api" .-> pyo3
    pc -. "stub-gen" .-> stubgen
    stubgen --> pyo3
    pc -. "viz" .-> plotters
    pc -. "viz-interactive" .-> winit & softbuffer
    pc -. "dev" .-> criterion

Les implications de features (Cargo.toml) : extension-module ⊃ python-api (active pyo3, mais demande à pyo3 de ne pas lier libpython — l’interpréteur hôte le fournit) ; viz-interactive ⊃ viz ; stub-gen ⊃ python-api. Le confinement annoté ci-dessus est la raison pour laquelle le cœur de calcul reste compilable et testable sans aucune de ces crates optionnelles.

Les trois couches, et pourquoi

CoucheRôleNe dépend pas de
containers/structures de données + invariantsops/, py/
ops/opérateurs croisant des conteneurspy/
py/exposition Python (PyO3), miroir 1:1—
python/pyrucast/rangement du module plat _pyrucast en sous-modules—

Cette stratification garde le cœur de calcul testable sans Python (cargo test) et réutilisable en pure crate Rust. Le models/ est à part : ce sont les physiques, branchées sur les conteneurs (Model/SubModel) via le trait SubModelKind — voir Ajouter une physique.

Où ajouter quoi ?

Pour ajouter…Toucher principalement
un type d’élémentatoms/element_kind/<nom>.rs + 2 lignes dans atoms/element_kind/mod.rs + 2 dans atoms/element_type.rs (guide)
une physiquemodels/<nom>.rs (physique, tests et opérateur via physics_operator!) + 2 lignes dans containers/model.rs + le raccord pub use / add_function (guide)
un opérateurops/<conteneur produit>/<nom>.rs + son wrapper py/ops/<même module>.rs + son ré-export dans python/pyrucast/<même module>.py
un objet conteneurcontainers/<nom>.rs + py/<nom>.rs + son ré-export dans python/pyrucast/__init__.py + un chapitre de doc

Le troisième site est facile à oublier. Une fonction libre exposée par PyO3 atterrit dans le module plat _pyrucast ; tant qu’elle n’est pas ré-exportée dans python/pyrucast/<module>.py, elle n’existe pas dans l’API publique. Les homonymes y reprennent au passage leur vrai nom (consolidate_mesh as consolidate) — voir Conventions & philosophie.

Conventions & philosophie

Trois natures de types

L’arborescence sépare trois choses que le mot « objet » confond, et c’est elle qui rend la convention lisible sans qu’on ait à la relire :

  • conteneurs (containers/) — ce dont une partie est encore de la même nature : la moitié d’un maillage est un maillage, la moitié d’un champ est un champ. Les sept agrégats et leurs vues Sub*. Seul un conteneur peut être le sujet d’un opérateur ;
  • atomes (atoms/) — les insécables : Node, Cell, Element (désignateurs), ElementType, Point3, RgbColor (valeurs). Un atome compose (node | node → Mesh) mais ne se décompose pas ; il est donc toujours argument, jamais self. Piège à connaître : Cell s’indexe (cell[k] → Node) sans être un conteneur, car ses parties sont d’une autre nature ;
  • magasin (coords.rs) — Coords, ni conteneur ni atome : il contient des nœuds, d’une autre nature que lui, et n’offre ni [], ni |, ni vue Sub*.

Méthodes vs fonctions libres

La règle qui décide si une opération est une méthode d’un conteneur ou une fonction libre d’un module ops/ est figée dans le fichier CONVENTIONS.md à la racine du dépôt. En résumé :

  • méthode — accesseur, mutation préservant l’invariant, ou vue dérivée d’un seul conteneur (mesh.cell_count, field.set(...), field.components()) ;
  • fonction libre ops::<module> — tout opérateur qui croise des conteneurs ou appartient à une famille d’opérateurs (ops::node_field::restrict(field, mesh), ops::matrix::stiffness(model, mat), ops::mesh::consolidate(mesh)).

Un constructeur nommé échappe aux deux : il fabrique son propre type, il reste donc sur le type (FiniteElementSpace.lagrange1(mesh), Matrix.block(...)). Mais l’exception s’arrête au pluriel : une famille qui grossit — le catalogue de physiques d’un Model, 28 entrées — se range comme toute famille d’opérateurs, dans le module du conteneur produit. Deux raisons, l’une de principe, l’autre très concrète : en Python une classmethod s’atteint aussi depuis une instance, si bien que m.heat_conduction(fes) s’exécute en jetant m en silence, et l’auto-complétion d’un objet se remplit de verbes qui ne s’adressent pas à lui. D’où pyrucast.model.heat_conduction(fes) et non Model.heat_conduction(fes) (rupture du 2026-08-25).

Où vit une fonction libre : le conteneur produit

Un module d’ops/ rassemble les opérateurs qui produisent le même conteneur, et porte son nom. On range par la sortie, jamais par l’entrée : gradient(field, fespace) produit un ElementField, donc il vit dans element_field, à côté de deformation — pas dans node_field.

moduleproduit
meshun Mesh (mailleurs, transformations, select)
node_fieldun NodeField (dérivations, assemblage nodal)
element_fieldun ElementField (cinématique, matériaux, comportement)
matrixune Matrix (les assembleurs)
modelun Model (les déclarations de physique)
coordsécrit dans le magasin (set, displace)

Ceux qui ne produisent aucun conteneur échappent à la règle par construction et se rangent par activité : measure (réductions à un nombre), geom (requêtes géométriques), export (effets de bord).

Troisième cas : l’opérateur générique, dont le produit est bien un conteneur mais pas un conteneur déterminé — abs rend un NodeField ou un ElementField selon son argument, la règle ne désigne donc pas un module. Ceux-là se rangent par domaine (field) et restent des fonctions libres à part entière.

Une seule exception, nommée et assumée : solver produit un NodeField et devrait rejoindre node_field. Il garde son nom parce que plusieurs familles distinctes produisent un champ nodal — dérivation, assemblage, résolution — et que seule la résolution se cherche par son propre nom.

Corollaire pratique : un module ne contient jamais deux opérateurs qui ne diffèrent que par leur conteneur. Le qualificatif appartient au nom du module, pas à celui de la fonction — d’où trois consolidate homonymes et sans ambiguïté, au lieu de trois fonctions suffixées au même endroit.

Le verbe exposé aussi en méthode

Une fonction libre garde sa forme canonique — c’est elle qui est documentée et qui définit l’opération. Elle est en plus exposée comme méthode de son premier argument si, et seulement si, les trois conditions tiennent :

  1. le premier argument est le sujet — l’objet qu’on transforme ;
  2. le retour est un conteneur — sinon il n’y a rien à composer ;
  3. l’opération a un sens pour toute instance du type.

La troisième est celle qu’on oublie, et c’est la plus coûteuse : une méthode promet, elle apparaît dans l’auto-complétion de chaque objet du type. u.deformation(fes) s’afficherait sur tous les champs nodaux alors qu’elle exige des composantes u_x/u_y/u_z ; t.thermal_strain(...) sur tous les champs par éléments alors qu’elle exige une température. Ces opérations restent des fonctions libres seules.

La ligne de partage : une précondition structurelle est admise (triangulate_surface veut un contour fermé), une exigence de sens porté par les noms de composantes ne l’est pas — à moins que le nom cherché ne soit un argument, ce qui la rend structurelle à son tour : divergence(field, "sigma") ne promet rien sur field, il dit quoi y chercher.

Pour tester la condition sans se tromper, lire la méthode avec un receveur quelconque, pas avec l’exemple bien nommé : stresses.internal_forces() sonnerait juste, mais c’est le nom de la variable qui ferait le travail — field.internal_forces() révèle que le type ne promet rien. Son sujet est le modèle, qui promet ses physiques, et c’est là que la méthode vit.

# chaining, when the three conditions hold:
peau = maillage.skin().consolidate()
libre = champ.select(ge=0.0)
eps = u.gradient(fes)

# forme canonique seule, sinon :
eps = pyrucast.element_field.deformation(u, fes)  # exige un déplacement
f = pyrucast.node_field.merge(a, b)  # symétrique : `a | b` suffit

Deux conséquences à retenir. Un ordre d’arguments qui ne met pas le sujet en tête est un défaut à corriger, pas une raison de renoncer à la méthode. Et une opération symétrique n’a pas de méthode : a.merge(b) suggérerait que l’ordre compte, alors que merge est l’alias nommé de a | b.

Enfin, le nom peut changer entre les deux formes. Le nom complet est toujours « qualificatif + verbe » ; la fonction libre reçoit le qualificatif de son module, la méthode n’en a pas et doit le porter : matrix.stiffness(model, mats) d’un côté, model.stiffness_matrix(mats) de l’autre. Quand la sortie est du type du sujet il n’y a rien à qualifier, et le nom ne bouge pas (mesh.consolidate(m) / m.consolidate()).

Le miroir Python

Le binding Python est un miroir 1:1 : une fonction Rust devient une fonction Python, une méthode reste une méthode. On vise le style numpy/scipy (et l’héritage cast3m) — des opérateurs nommés plutôt que des chaînes de méthodes. Le module de production est reflété par un sous-module : une fonction libre ops::<module>::f est exposée comme pyrucast.<module>.f. Les conteneurs et les atomes restent des classes au top-level (pyrucast.Coords, pyrucast.Mesh, pyrucast.Node, …) :

import pyrucast

# functions (operators), laid out by produced container — not methods:
poi = pyrucast.mesh.to_poi1(mesh)
coords = pyrucast.node_field.positions(mesh)
eps = pyrucast.element_field.deformation(u, fes)
K = pyrucast.matrix.stiffness(model, materials)
sol = pyrucast.solver.solve(K, rhs)

Le miroir est sans exception : aucune fonction libre ne vit au top-level Python. Les trois consolidations suivent leur module — pyrucast.mesh.consolidate, pyrucast.node_field.consolidate, pyrucast.element_field.consolidate — comme leurs homologues Rust.

L’extension compilée _pyrucast est, elle, plate : les homonymes y portent un #[pyo3(name = …)] distinct (consolidate_mesh, …) et la couche Python pure les ré-exporte sous leur vrai nom dans le bon sous-module. C’est un détail du namespace privé, pas une entorse au miroir.

Un accesseur sans argument qui rend un scalaire — une taille, une dimension, un nom, un drapeau — se lit comme une propriété, sans parenthèses : mesh.cell_count, fes[0].gauss_count, fes[0].element_type, zone.is_sealed. C’est l’idiome Python d’une valeur lue sur un objet, celui de a.size ou a.ndim chez numpy. Ce qui prend un argument, ou rend une liste ou un autre objet, reste une méthode : components(), coords(), mesh().

Erreurs

Toute l’API publique renvoie pyrucast::Result<T>, alias de Result<T, PyrucastError>.

PyrucastError est l’unique type d’erreur de la librairie. Côté Python, il est converti automatiquement en RuntimeError.

Rust :

#[test]
fn une_erreur_se_lit_et_se_filtre() {
    // Dimension nulle — erreur attendue.
    let err = Coords::new(0).unwrap_err();
    assert!(err.to_string().contains("dim must be ≥ 1"));

    // Pattern matching on the variants.
    match Coords::new(0) {
        Ok(_) => unreachable!(),
        Err(pyrucast::PyrucastError::Message(msg)) => println!("erreur : {msg}"),
        Err(e) => println!("autre erreur : {e}"),
    }
}

Python :

import pyrucast

try:
    c = pyrucast.Coords(0)  # dimension nulle
except RuntimeError as e:
    print(f"erreur : {e}")  # erreur : dim must be ≥ 1

Sérialisation : un seul mécanisme

Le trait Portable (implémenté automatiquement pour tout type serde::Serialize + DeserializeOwned) produit un format binaire portable Linux ↔ Windows. C’est le socle de la sauvegarde fichier : un graphe d’objets écrit dans un conteneur versionné, relu en préservant le partage.

Rust — sérialisation manuelle d’un type quelconque :

use pyrucast::archive::Portable;
use pyrucast::models::symmetry::MaterialSymmetry;

#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct Pt {
    x: f64,
    y: f64,
}

#[test]
fn un_seul_mecanisme_de_serialisation() {
    let original = Pt { x: 1.5, y: -2.0 };
    let bytes = original.to_bytes().unwrap();
    let restored = Pt::from_bytes(&bytes).unwrap();
    assert_eq!(original, restored);
}

Python : Portable n’est pas exposé côté Python — c’est une brique interne. La sérialisation depuis Python passe par pyrucast.save / pyrucast.load, qui écrivent un graphe entier plutôt qu’un objet isolé.

Definition of Done par objet

Un objet n’est considéré comme terminé que lorsque les six points suivants sont verts :

  1. Struct Rust adressable par un Handle<T> typé.
  2. Les trois niveaux de listing : Display (résumé), Debug (structure), Dump (contenu).
  3. Tests unitaires Rust, et un doctest portant un exemple exécutable sur chaque item public — ignore proscrit, no_run si l’exemple ne peut pas tourner.
  4. Binding PyO3 (__str__ / __repr__ / dump()).
  5. Tests Python (tests/python/), la surface pyo3 comprise.
  6. Chapitre de cette documentation, dont le code est inclus depuis un test ou un exemple, jamais recopié dans la page.

Les points 3, 5 et 6 sont détaillés page Documentation et tests : quel type d’exemple vit où, et qui le vérifie.

Dépendances approuvées

Le socle figé est :

  • toujours lié — serde + bincode (persistance), nalgebra + nalgebra-sparse (primitives et stockage creux), faer (LU creux du solveur), rayon (parallélisme), parking_lot (verrous des objets), paste (macros d’agrégat) ;
  • optionnel, derrière une feature — pyo3 et pyo3-stub-gen (binding et stub), plotters / winit / softbuffer (visualisation) ;
  • outillage — maturin, mdbook, ruff, criterion (bancs).

Chaque dépendance est confinée à un étage et ne fuit pas hors de lui — le graphe des dépendances dit lequel pour chacune. Toute autre dépendance, Rust ou Python, requiert un accord explicite.

Correspondance Rust ↔ Python

Cette page liste, par module, les structures (exposées en classes Python) et les fonctions libres (exposées en fonctions de module Python). Elle matérialise la règle de Conventions :

  • une structure containers::…::Foo est exposée sous le même nom pyrucast.Foo (le wrapper PyO3 interne PyFoo est masqué) ;
  • une fonction libre ops::<module>::f est exposée dans le sous-module de même nom : pyrucast.<module>.f. Le module Rust porte le nom du conteneur produit (mesh, node_field, element_field, matrix, model, coords), ou de l’activité quand il ne produit aucun conteneur (measure, geom, export) ; solver est l’exception nommée. Seuls trois verbes vivent au top-level, faute de conteneur produit : save et load (cf. archive) et spill_stats ;
  • une surcharge d’opérateur Rust devient un dunder Python (Add → __add__, Index → __getitem__, …) ;
  • un constructeur nommé Rust devient un classmethod / constructeur Python.

Les tableaux ci-dessous donnent la forme canonique — la fonction libre. Beaucoup de ces opérations sont aussi exposées comme méthode de leur sujet, selon la règle des trois conditions (Conventions) : premier argument sujet, retour conteneur, sens pour toute instance du type. La règle étant mécanique, la liste n’est pas recopiée ici — elle est vérifiée par un test (tests/python/test_method_exposure.py), qui lit le stub et échoue si une opération éligible perd sa méthode. Ce test porte aussi la liste des exclusions, chacune avec sa raison.

Deux points à connaître, illustrés plus bas : le nom peut changer entre les deux formes (matrix.stiffness(model, mats) / model.stiffness_matrix(mats), element_field.sub_material_field(sub, …) / sub_model.material_field(…)), et les cinématiques (deformation, beam_deformation, shell_deformation, thermal_strain) n’ont pas de méthode : elles exigent des composantes nommées, elles n’auraient pas de sens sur un champ quelconque. von_mises non plus : elle ne s’applique qu’à des contraintes. internal_forces, elle, a sa méthode, sur son sujet le modèle : model.internal_forces(state, solution, materials).

filter_components et rename_component n’ont que la forme méthode (f.filter_components(["u_x"]), f.rename_component("U", "DX")) : un seul conteneur, de petits arguments, une vue dérivée — R1 en fait du vocabulaire du champ, pas un opérateur.

La complétude du miroir est elle aussi vérifiée par un test (tests/python/test_mirror_completeness.py), dans les deux sens : aucun opérateur Rust sans binding Python, et aucune fonction Python sans opérateur Rust. Les dérogations y vivent avec leur raison.

Source de vérité : le #[pymodule] de src/lib.rs (enregistrement des classes et fonctions) et le stub python/pyrucast/_pyrucast/__init__.pyi (signatures typées). Cette page en est un instantané, à régénérer à la main si l’API bouge.

Structures ↔ classes

Le nom de la classe Python est identique au nom de la structure Rust.

Module RustStructure RustClasse PythonChapitre
coordsCoordspyrucast.CoordsCoords
atoms::nodeNodepyrucast.NodeNœud
containers::meshSubMeshpyrucast.SubMesh (vue, via mesh[i])Maillage
containers::meshMeshpyrucast.MeshMaillage
atoms::cellCellpyrucast.CellMaillage
containers::finite_element_spaceSubFiniteElementSpacepyrucast.SubFiniteElementSpaceEspace EF
containers::finite_element_spaceFiniteElementSpacepyrucast.FiniteElementSpaceEspace EF
atoms::elementElementpyrucast.ElementEspace EF
containers::node_fieldSubNodeFieldpyrucast.SubNodeField (vue, via node_field[i])Champ aux nœuds
containers::node_fieldNodeFieldpyrucast.NodeFieldChamp aux nœuds
containers::element_fieldSubElementFieldpyrucast.SubElementField (vue, via element_field[i])Champ aux points de Gauss
containers::element_fieldElementFieldpyrucast.ElementFieldChamp aux points de Gauss
containers::matrixSubMatrixpyrucast.SubMatrix (vue, via matrix[i])Matrice creuse
containers::matrixMatrixpyrucast.MatrixMatrice creuse
containers::modelSubModelpyrucast.SubModel (vue, via model[i])Modèle physique
containers::modelModelpyrucast.ModelModèle physique
containers::evolutionSubEvolutionpyrucast.SubEvolution (constructible — voir ci-dessous)Évolution
containers::evolutionEvolutionpyrucast.EvolutionÉvolution

Une classe Python n’a pas d’homologue Rust : pyrucast.Array, le tableau 1-D en lecture seule que rendent les fonctions d’échange (export.to_arrays…), lisible sans copie par numpy.asarray.

Quelques types Rust ne sont pas exposés en classes Python : ce sont des détails d’implémentation (SubModelKind, le trait que chaque physique implémente derrière SubModel ; DofOrdering, l’ordonnancement des DOFs d’une SubMatrix ; SubValue, OutOfRange, ValueKind et Interpolated, internes à l’Evolution — une valeur tabulée se passe directement en scalaire ou en champ, la politique hors-plage en chaîne "error"/"clamp"/"extrapolate").

Les sous-objets Sub* (SubMesh, SubFiniteElementSpace, SubNodeField, SubElementField, SubMatrix, SubModel) ne se construisent pas directement côté Python : ce sont des vues obtenues par indexation de leur parent (parent[i]). On construit toujours au niveau parent — Mesh(coords, type), FiniteElementSpace(mesh), ElementField(fes, comps), Matrix.block(...) — ou par l’opérateur qui rend ce parent (model.heat_conduction(fes)), et on compose plusieurs zones avec | (union — Rust : union). Voir la règle « Agrégats : un ou plusieurs » de CONVENTIONS.md.

Exception : SubEvolution. Seul sous-objet à la fois vue (via evolution[i]) et constructible directement — SubEvolution([(t0, v0), (t1, v1), …]) — car une courbe tabulée n’a pas de « parent » géométrique qui la définirait. On compose ensuite les courbes par zone avec | (cf. Évolution).

Fonctions ↔ fonctions

Les fonctions ops prennent leurs conteneurs par référence et renvoient Result<T> (converti en exception Python RuntimeError), sauf celles qui ne peuvent pas échouer (update_contact, mask_sub). Les signatures ci-dessous omettent souvent le & et le Result pour la lisibilité.

ops::mesh — construction et transformation de maillages

Les mailleurs prennent element_type=None en Python : "SEG2" pour line, circle et arc, "QUA4" pour transfinite, et pour sweep la surface du degré des lignes ("QUA4" entre deux SEG2, "QUA8" entre deux SEG3).

Rust (ops::mesh::…)Python (pyrucast.mesh.…)
from_live_nodes(coords: Handle<Coords>) -> Meshfrom_live_nodes(coords) -> Mesh
poi1_from_nodes(nodes: &[Node]) -> Meshpoi1_from_nodes(nodes) -> Mesh
line(a: &Node, b: &Node, n_elems: usize, element_type: ElementType) -> Meshline(a, b, n_elems, element_type=None) -> Mesh
circle(center: &Node, normal: &[f64], radius: f64, n_elems: usize, element_type: ElementType) -> Meshcircle(center, normal, radius, n_elems, element_type=None) -> Mesh
arc(node_a: &Node, center: &Node, node_b: &Node, n_elems: usize, element_type: ElementType) -> Mesharc(a, center, b, n_elems, element_type=None) -> Mesh
sweep(mesh_a: &Mesh, mesh_b: &Mesh, n_layers: usize, element_type: ElementType) -> Meshsweep(mesh_a, mesh_b, n_layers, element_type=None) -> Mesh
transfinite(side1: &Mesh, side2: &Mesh, side3: &Mesh, side4: &Mesh, element_type: ElementType) -> Meshtransfinite(side1, side2, side3, side4, element_type=None) -> Mesh
sweep_solid(mesh_a: &Mesh, mesh_b: &Mesh, n_layers: usize) -> Meshsweep_solid(mesh_a, mesh_b, n_layers) -> Mesh
extrude(mesh: &Mesh, direction: &[f64], n_layers: usize) -> Meshextrude(mesh, direction, n_layers) -> Mesh
revolve(mesh: &Mesh, angle: f64, n_layers: usize, center: &[f64], axis: Option<&[f64]>) -> Meshrevolve(mesh, angle, n_layers, center, axis=None) -> Mesh (une ligne qui touche l’axe s’y referme en TRI3 / TRI6)
to_quadratic(mesh: &Mesh) -> Meshto_quadratic(mesh) -> Mesh
convert(mesh: &Mesh, target: ElementType) -> Meshconvert(mesh, element_type) -> Mesh
copy(mesh: &Mesh, new_nodes: bool) -> Meshcopy(mesh, new_nodes=True) -> Mesh
translate(mesh: &Mesh, vector: &[f64]) -> Meshtranslate(mesh, vector) -> Mesh
rotate(mesh: &Mesh, angle: f64, center: &[f64], axis: Option<&[f64]>) -> Meshrotate(mesh, angle, center, axis=None) -> Mesh
symmetry_point(mesh: &Mesh, center: &[f64]) -> Meshsymmetry_point(mesh, center) -> Mesh
symmetry_line(mesh: &Mesh, a: &[f64], b: &[f64]) -> Meshsymmetry_line(mesh, a, b) -> Mesh
symmetry_plane(mesh: &Mesh, a: &[f64], b: &[f64], c: &[f64]) -> Meshsymmetry_plane(mesh, a, b, c) -> Mesh
project_on_sphere(mesh: &Mesh, center: &[f64], radius: f64) -> Meshproject_on_sphere(mesh, center, radius) -> Mesh
triangulate_surface(contour: &Mesh, element_type: ElementType, target_size: Option<f64>) -> Meshtriangulate_surface(contour, element_type, size=None) -> Mesh
pave_surface(contour: &Mesh, element_type: ElementType, target_size: Option<f64>, prefer_quads: bool, relax: FrontRelax) -> Meshpave_surface(contour, element_type, size=None, prefer_quads=False, relax=None) -> Mesh
grid_surface(contour: &Mesh, element_type: ElementType, target_size: Option<f64>, band: usize, prefer_quads: bool, relax: FrontRelax) -> Meshgrid_surface(contour, element_type, size=None, band=0, prefer_quads=False, relax=None) -> Mesh
grid_surface2(contour: &Mesh, element_type: ElementType, target_size: Option<f64>, band: usize, prefer_quads: bool, relax: FrontRelax) -> Meshgrid_surface2(contour, element_type, size=None, band=0, prefer_quads=False, relax=None) -> Mesh
triangulate_volume(envelope: &Mesh, target_size: Option<f64>, allow_surface_nodes: bool) -> Meshtriangulate_volume(envelope, size=None, allow_surface_nodes=False) -> Mesh
pave_volume(envelope: &Mesh, layers: usize, thickness: Option<f64>, core_size: Option<f64>) -> Meshpave_volume(envelope, layers=1, thickness=None, size=None) -> Mesh
border(mesh: &Mesh, angle_deg: Option<f64>) -> Meshborder(mesh, angle_deg=None) -> Mesh
skin(mesh: &Mesh, angle_deg: Option<f64>) -> Meshskin(mesh, angle_deg=None) -> Mesh
orient(mesh: &Mesh) -> Meshorient(mesh) -> Mesh
invert(mesh: &Mesh) -> Meshinvert(mesh) -> Mesh
chain(mesh: &Mesh) -> Meshchain(mesh) -> Mesh
barycenter(mesh: &Mesh) -> Meshbarycenter(mesh) -> Mesh
to_poi1(mesh: &Mesh) -> Meshto_poi1(mesh) -> Mesh
elements_on(mesh: &Mesh, points: &Mesh, strict: bool) -> Meshelements_on(mesh, points, strict=True) -> Mesh
points_in_sphere(mesh: &Mesh, center: &[f64], radius: f64, tol: Option<f64>) -> Meshpoints_in_sphere(mesh, center, radius, tol=None) -> Mesh
points_on_sphere(mesh: &Mesh, center: &[f64], radius: f64, tol: Option<f64>) -> Meshpoints_on_sphere(mesh, center, radius, tol=None) -> Mesh
points_on_plane(mesh: &Mesh, origin: &[f64], normal: &[f64], tol: Option<f64>) -> Meshpoints_on_plane(mesh, origin, normal, tol=None) -> Mesh
points_below_plane(mesh: &Mesh, origin: &[f64], normal: &[f64], tol: Option<f64>) -> Meshpoints_below_plane(mesh, origin, normal, tol=None) -> Mesh
points_on_line(mesh: &Mesh, a: &[f64], b: &[f64], tol: Option<f64>) -> Meshpoints_on_line(mesh, a, b, tol=None) -> Mesh
points_in_cylinder(mesh: &Mesh, base: &[f64], top: &[f64], radius: f64, tol: Option<f64>) -> Meshpoints_in_cylinder(mesh, base, top, radius, tol=None) -> Mesh
points_on_cylinder(mesh: &Mesh, base: &[f64], top: &[f64], radius: f64, tol: Option<f64>) -> Meshpoints_on_cylinder(mesh, base, top, radius, tol=None) -> Mesh
points_in_cone(mesh: &Mesh, base: &[f64], top: &[f64], base_radius: f64, top_radius: f64, tol: Option<f64>) -> Meshpoints_in_cone(mesh, base, top, base_radius, top_radius=0.0, tol=None) -> Mesh
points_on_cone(mesh: &Mesh, base: &[f64], top: &[f64], base_radius: f64, top_radius: f64, tol: Option<f64>) -> Meshpoints_on_cone(mesh, base, top, base_radius, top_radius=0.0, tol=None) -> Mesh
points_in_torus(mesh: &Mesh, center: &[f64], axis: &[f64], major_radius: f64, minor_radius: f64, tol: Option<f64>) -> Meshpoints_in_torus(mesh, center, axis, major_radius, minor_radius, tol=None) -> Mesh
points_on_torus(mesh: &Mesh, center: &[f64], axis: &[f64], major_radius: f64, minor_radius: f64, tol: Option<f64>) -> Meshpoints_on_torus(mesh, center, axis, major_radius, minor_radius, tol=None) -> Mesh
merge_nodes(mesh: &Mesh, tol: f64, in_place: bool) -> Meshmerge_nodes(mesh, tol, in_place=False) -> Mesh
cleanup(mesh: &Mesh) -> Meshcleanup(mesh) -> Mesh
merge_triangles(mesh: &Mesh) -> Meshmerge_triangles(mesh) -> Mesh
regularize(mesh: &Mesh, sweeps: usize, angular: bool, in_place: bool) -> Meshregularize(mesh, sweeps=20, angular=True, in_place=False) -> Mesh
read_gmsh(coords: Handle<Coords>, path: &Path) -> Vec<(String, Mesh)>read_gmsh(coords, path) -> dict[str, Mesh]
read_gmsh_str(coords: Handle<Coords>, text: &str) -> Vec<(String, Mesh)>read_gmsh_str(coords, text) -> dict[str, Mesh]
from_arrays(coords: Handle<Coords>, node_tags: &[T], node_coords: &[f64], blocks: &[CellBlock<T>], node_values: &[NodeValues<T>], cell_values: &[CellValues<T>], order: NodeOrder) -> Importedfrom_arrays(coords, node_tags, node_coords, blocks, *, node_fields=[], cell_fields=[], order="pyrucast") -> (dict[str, Mesh], list[NodeField], list[ElementField])
element_type_from_gmsh(code: u32) -> ElementTypeelement_type_from_gmsh(code) -> str
gauss_to_external(et: ElementType, order: NodeOrder, ext_ref_nodes: &[f64]) -> (Vec<f64>, Vec<f64>)gauss_to_external(element_type, ref_nodes, order) -> (list, list)
match_gauss(et: ElementType, order: NodeOrder, ext_ref_nodes: &[f64], ext_xi: &[f64], ext_weights: &[f64]) -> Vec<usize>match_gauss(element_type, ref_nodes, xi, weights, order) -> list[int]
— (exige l’interpréteur)from_gmsh(coords, *, dim=-1, tag=-1, views=True) -> (dict[str, Mesh], dict)
— (exige l’interpréteur)from_medcoupling(coords, source, *, mesh_name=None) -> (dict[str, Mesh], dict)
consolidate(mesh: &Mesh) -> Meshconsolidate(mesh) -> Mesh
select_nodes(field: &NodeField, band: &Band, …) -> Mesh / select_cells(field: &ElementField, …) -> Mesh, et pour une zone select_sub_nodes / select_sub_cellsselect(field, ge=None, gt=None, le=None, lt=None, components=None) -> Mesh (dispatch par type ; part d’un champ mais rend un maillage, d’où son rangement ici)

Les mailleurs longs (triangulate_surface, pave_surface, grid_surface, grid_surface2, triangulate_volume, pave_volume) ont en Rust une variante *_cancellable qui reçoit un jeton d’interruption ; en Python, l’interruption (Ctrl+C) est câblée dans la fonction nominale.

ops::node_field — opérateurs produisant un champ aux nœuds

Rust (ops::node_field::…)Python (pyrucast.node_field.…)
positions(mesh: &Mesh, components: Option<Vec<String>>) -> NodeFieldpositions(mesh, components=None) -> NodeField
divergence(field: &ElementField, prefix: &str) -> NodeFielddivergence(field, prefix) -> NodeField
restrict(field: &NodeField, mesh: &Mesh) -> NodeFieldrestrict(field, mesh) -> NodeField
project_node(field: &NodeField, mesh: &Mesh, target: &Mesh, tol: f64) -> NodeField / project_element(field: &ElementField, …) -> NodeFieldproject(field, mesh, target, tol=1e-6) -> NodeField (dispatch par type, aussi méthode field.project(mesh, target, tol=1e-6) sur les quatre saveurs de champ ; un champ aux nœuds est interpolé par les mailles de mesh, un champ aux points de Gauss est lu par l’ajustement propre à chaque maille, rien n’est moyenné entre mailles ; 0 hors de mesh)
restrict_like(field: &NodeField, target: &NodeField) -> NodeFieldrestrict_like(field, target) -> NodeField
merge(a: &NodeField, b: &NodeField) -> NodeFieldmerge(a, b) -> NodeField
consolidate(field: &NodeField) -> NodeFieldconsolidate(field) -> NodeField
mask(field: &NodeField, band: &Band, …) -> NodeField / mask_sub(sub: &SubNodeField, …) -> SubNodeFieldmask(field, ge=None, gt=None, le=None, lt=None, components=None) -> NodeField (champ 0/1 de même structure ; sucre field >= x). Accepte aussi un SubNodeField
internal_forces(model: &Model, state: &ElementField, solution: &NodeField, materials: &ElementField) -> NodeFieldinternal_forces(model, state, solution, materials) -> NodeField, aussi model.internal_forces(state, solution, materials)
external_forces(model: &Model, materials: &ElementField) -> NodeFieldexternal_forces(model, materials) -> NodeField
sum_to_nodes(field: &ElementField) -> NodeFieldsum_to_nodes(field) -> NodeField (champ à un point par maille ; chaque nœud reçoit la somme des mailles qui le touchent)

internal_forces (BSIG, ∫ Bᵀ σ) et external_forces sont des assemblages, mais leur résultat est un vecteur nodal et non un opérateur : on se range par la sortie.

ops::coords — écriture dans le magasin de coordonnées, calcul dans une configuration

Rust (ops::coords::…)Python (pyrucast.coords.…)
set(field: &NodeField, components: Option<Vec<String>>) -> ()set(field, components=None) -> None
displace(field: &NodeField, components: Option<Vec<String>>) -> ()displace(field, components=None) -> None
displace_from(field: &NodeField, reference: usize, target: usize, components: Option<Vec<String>>) -> ()displace_from(field, reference, target, components=None) -> None (pose la configuration target en X + u, en absolu ; la configuration active ne change pas)
in_config(coords: &Handle<Coords>, config: usize, f: impl FnOnce() -> Result<R>) -> Result<R>méthode Coords.in_config(config) -> InConfig, gestionnaire de contexte : with coords.in_config(i): (le bloc calcule sur la configuration config, celle d’avant revient à la sortie, même après une erreur). Chaque langage a sa forme idiomatique : fermeture en Rust, with en Python

ops::element_field — opérateurs produisant un champ aux points de Gauss

Rust (ops::element_field::…)Python (pyrucast.element_field.…)
gradient(field: &NodeField, fespace: &FiniteElementSpace) -> ElementFieldgradient(field, fespace) -> ElementField
deformation(u: &NodeField, fespace: &FiniteElementSpace, measure: StrainMeasure) -> ElementFielddeformation(u, fespace, measure="linear") -> ElementField (StrainMeasure passe par son nom : "linear", "gradient", "green_lagrange", "right_cauchy_green", "left_cauchy_green", "hencky", "euler_almansi", "jacobian")
interp_to_gauss(field: &NodeField, fespace: &FiniteElementSpace) -> ElementFieldinterp_to_gauss(field, fespace) -> ElementField
thermal_strain(temperature: &ElementField, material: &ElementField, fespace: &FiniteElementSpace, t_ref: f64, measure: StrainMeasure) -> ElementFieldthermal_strain(temperature, materials, fespace, t_ref, measure="linear") -> ElementField (la dilatation exacte dans la mesure de la loi)
strain(model: &Model, solution: &NodeField, materials: &ElementField) -> ElementFieldstrain(model, solution, materials) -> ElementField (EPSI : l’entrée de chaque loi ; aussi model.strain(…))
elastic_stress(model: &Model, strain: &ElementField, materials: &ElementField) -> ElementFieldelastic_stress(model, strain, materials) -> ElementField (σ = D:ε ; aussi model.elastic_stress(…))
von_mises(model: &Model, stress: &ElementField, coefficients: &[f64; 6]) -> ElementFieldvon_mises(model, stress, coefficients=[0.5, 0.5, 0.5, 1.5, 1.5, 1.5]) -> ElementField (von Mises, ou Hill avec [F, G, H, L, M, N] ; défaut Rust : la constante VON_MISES ; pas de méthode)
elastic_strain(model: &Model, stress: &ElementField, materials: &ElementField) -> ElementFieldelastic_strain(model, stress, materials) -> ElementField (ε = D⁻¹:σ ; aussi model.elastic_strain(…))
stress_measure(stress: &ElementField, gradient: &ElementField, target: StressMeasure) -> ElementFieldstress_measure(stress, gradient, target) -> ElementField (StressMeasure par son nom : "cauchy", "kirchhoff", "pk1", "pk2", "hencky" ; pas de méthode : elle exige une contrainte et un gradient nommés)
rotate_frame(field: &ElementField, gradient: &ElementField) -> ElementFieldrotate_frame(field, gradient) -> ElementField (axes V1*, V2* tournés par la rotation R de F = R U ; pas de méthode : elle exige des axes et un gradient nommés)
cell_integral(field: &ElementField, target: &FiniteElementSpace) -> ElementFieldcell_integral(field, target) -> ElementField (target : un point par maille, quadrature REDUCED)
shell_deformation(field: &NodeField, fespace: &FiniteElementSpace, formulation: ShellFormulation) -> ElementFieldshell_deformation(field, fespace, formulation) -> ElementField
beam_deformation(field: &NodeField, fespace: &FiniteElementSpace, material: &ElementField) -> ElementFieldbeam_deformation(field, fespace, material) -> ElementField (1-D, plan ou spatial selon le maillage ; le matériau est requis, l’interpolation dépendant de Φ)
consolidate(field: &ElementField) -> ElementFieldconsolidate(field) -> ElementField (fusionne les zones d’une même fespace)
mask(field: &ElementField, band: &Band, …) -> ElementField / mask_sub(sub: &SubElementField, …) -> SubElementFieldmask(field, ge=None, …) -> ElementField ; accepte aussi un SubElementField
sub_material_field(sub: &SubModel, components_and_values: &[(&str, f64)]) -> SubElementFieldsub_material_field(sub_model, components_and_values) -> SubElementField
material_field(model: &Model, components_and_values: &[(&str, f64)]) -> ElementFieldmaterial_field(model, components_and_values) -> ElementField
material_field_per_sub_model(model: &Model, per_sub_model: &[&[(&str, f64)]]) -> ElementFieldmaterial_field_per_sub_model(model, components_and_values_per_sub_model) -> ElementField
behavior::initial_state(model: &Model, materials: &ElementField) -> ElementFieldinitial_state(model, materials) -> ElementField (l’état de repos, prev du premier pas)
behavior::integrate(model, deformation, prev: &ElementField, materials, dt: Option<f64>) -> ElementFieldintegrate_behavior(model, deformation, materials, prev, dt=None) -> ElementField (COMP)

ops::evolution — opérateurs produisant une évolution

Rust (ops::evolution::…)Python (pyrucast.evolution.…)
along(field: &NodeField, line: &Mesh, components: &[&str]) -> Evolutionalong(field, line, components=None) -> Evolution, aussi méthode field.along(line, components=None) (une courbe par composante le long d’une ligne POI1, abscisse = distance parcourue ; None prend toutes les composantes)

ops::measure — réductions à un nombre

Rust (ops::measure::…)Python (pyrucast.measure.…)
integral(field: &NodeField, fespace, component) -> f64 / integral_element(field: &ElementField, component) -> f64integral(field, component, fespace=None) -> float (dispatch par type ; ∫ f dΩ, fespace requis pour un NodeField)
SubField::dot(&self, other) -> Result<f64> / Field::dot_field(&self, other) -> f64xty(x, y) -> float (dispatch par type ; produit scalaire global de deux champs)
SubField::xtx(&self) -> f64 / Field::xtx(&self) -> f64xtx(x) -> float (dispatch par type ; Σ v², norme au carré XTX)
SubField::xtx_components(&self, &[&str]) -> Result<f64>xtx(x, components=[…]) -> float (norme au carré restreinte à ces composantes)

ops::field — opérateurs génériques

Leur produit est un conteneur — toujours — mais pas un conteneur déterminé : il dépend de l’argument. La règle « un module par conteneur produit » ne désigne donc pas un module, et ils se rangent par domaine.

Rust (ops::field::…)Python (pyrucast.field.…)
psca<T: Pscal>(x: &T, y: &T) -> Tpsca(x, y) -> field (produit scalaire nœud par nœud, champ à une composante "psca"). Deux conteneurs en pairs et opération symétrique : fonction libre seule, pas de méthode
abs / sqrt / exp / log / log10 / cos / sin / tan / sinh / cosh / tanh <T: MapValues>(field: &T) -> Tmêmes noms pyrucast.field.…(field) — maths élément par élément (style numpy), un champ neuf du même type ; acceptent les quatre saveurs de champ (NodeField / SubNodeField / ElementField / SubElementField). Résultats non bornés : log de ≤ 0 → -inf/nan

La composition de zones passe par l’union (| Python / union Rust) ; l’arithmétique champ + scalaire et champ + champ par les opérateurs +, -, *, /, ** (cf. Opérateurs). Ni l’une ni l’autre n’est une fonction ops — merge(a, b) est juste un alias nommé de a | b.

ops::model — déclaration des physiques

Chaque opérateur rend un Model couvrant tout le support reçu (une zone par sous-espace) ; on compose les physiques hétérogènes avec |. Aucun n’a de forme méthode : le premier argument est le support que le modèle recouvre, pas un sujet qu’on transforme. Seul update_contact, qui transforme un modèle, en a une.

Rust (ops::model::…)Python (pyrucast.model.…)
heat_conduction(fespace: &FiniteElementSpace, symmetry: MaterialSymmetry) -> Modelheat_conduction(fespace, symmetry="isotropic") -> Model
fick(fespace: &FiniteElementSpace, species: &str, symmetry: MaterialSymmetry) -> Modelfick(fespace, species, symmetry="isotropic") -> Model
radiation(fespace: &FiniteElementSpace, target: &Model) -> Modelradiation(fespace, target) -> Model
flux(fespace: &FiniteElementSpace, target: &Model, dual: &str) -> Modelflux(fespace, target, dual) -> Model (dual="n" : le mode normal, une pression phi_n le long de la normale de la maille)
boundary_transfer(fespace: &FiniteElementSpace, target: &Model, variables: Vec<String>) -> Modelboundary_transfer(fespace, target, variables) -> Model — la duale de chaque variable est lue dans target
interface_transfer(side_a: &FiniteElementSpace, side_b: &FiniteElementSpace, target: &Model, variables: Vec<String>, tol: f64) -> Modelinterface_transfer(side_a, side_b, target, variables, tol=1e-9) -> Model — tol est une distance, dans l’unité du maillage ; défaut interface_transfer::DEFAULT_TOL
truss(fespace: &FiniteElementSpace, strain_measure: StrainMeasure) -> Modeltruss(fespace, strain_measure="linear") -> Model
elasticity(fespace: &FiniteElementSpace, kinematics: Kinematics, symmetry: MaterialSymmetry, strain_measure: StrainMeasure) -> Modelelasticity(fespace, kinematics, symmetry="isotropic", strain_measure="linear") -> Model
plasticity_perfect(fespace: &FiniteElementSpace, kinematics: Kinematics, strain_measure: StrainMeasure) -> Modelplasticity_perfect(fespace, kinematics, strain_measure="linear") -> Model
plasticity(fespace: &FiniteElementSpace, kinematics: Kinematics, law: PlasticLaw, strain_measure: StrainMeasure) -> Modelune fonction par loi : plasticity_isotropic, drucker_prager, ottosen, creep_norton, creep_blackburn, creep_lemaitre, viscoplasticity_chaboche, viscoplasticity_lemaitre_chaboche, gurson — toutes (fespace, kinematics, strain_measure="linear") -> Model, qui existent aussi en Rust, (fespace, kinematics, strain_measure)
mazars(fespace: &FiniteElementSpace, kinematics: Kinematics, strain_measure: StrainMeasure) -> Modelmazars(fespace, kinematics, strain_measure="linear") -> Model
damage(fespace: &FiniteElementSpace, kinematics: Kinematics, law: DamageLaw, strain_measure: StrainMeasure) -> Modelune fonction par loi : damage_tc, damage_sic_sic — (fespace, kinematics, strain_measure="linear") -> Model, qui existent aussi en Rust, (fespace, kinematics, strain_measure)
bernoulli(fespace: &FiniteElementSpace) -> Modelbernoulli(fespace) -> Model
timoshenko(fespace: &FiniteElementSpace) -> Modeltimoshenko(fespace) -> Model
shell(fespace: &FiniteElementSpace, formulation: ShellFormulation) -> Modelshell(fespace, formulation) -> Model ("thick" ou "kirchhoff")
dirichlet(imposed_mesh: &Mesh, target: &Model, variable: &str, sense: RelationSense) -> Modeldirichlet(imposed_mesh, target, variable, sense="=") -> Model
mpc(terms: &[(&Mesh, &str, f64)], target: &Model, sense: RelationSense) -> Modelmpc(terms, target, sense="=") -> Model — terms : liste de (mesh, variable, coefficient)
embedded(immersed: &Mesh, host: &Mesh, target: &Model, variables: Vec<String>, tol: f64) -> Modelembedded(immersed, host, target, variables, tol=1e-6) -> Model — tol est un jeu dans l’élément de référence, sans dimension ; défaut embedded::DEFAULT_TOL
contact(slave: &Mesh, master: &Mesh, target: &Model, variables: Vec<String>) -> Modelcontact(slave, master, target, variables) -> Model
update_contact(model: &Model, displacement: &NodeField) -> Modelupdate_contact(model, displacement) -> Model, et la méthode Model.update_contact(displacement)

Même ordre partout. Le support d’abord — l’espace EF balayé, ou les maillages qui portent une contrainte, celui qui porte les relations en tête (imposed_mesh, immersed, slave, les termes d’une MPC) —, puis le modèle target auquel on se rattache, puis le reste. La duale d’une variable n’est jamais un argument : elle se lit dans target (Model.dual_of).

Les noms, en Rust. Un nom seul s’emprunte (&str) ; une liste de noms se donne (Vec<String>), parce que le modèle la garde — comme les constructeurs de champs et de matrices. Les termes d’une MPC empruntent leurs maillages : ils restent à l’appelant.

Un argument, deux idiomes. Un argument que Python prend en mot-clé avec valeur par défaut (symmetry="isotropic", strain_measure="linear"), Rust le reçoit explicitement (MaterialSymmetry::Isotropic, StrainMeasure::Linear) : Rust n’a pas d’argument par défaut. Même nom, même ordre ; c’est physics_operator! qui écrit les deux faces. La documentation Rust de chaque fonction dit la valeur que Python prend par défaut, pour qu’un lecteur qui n’écrit que du Rust sache ce que l’autre obtient sans rien dire.

Le pli des lois. Rust nomme la loi par une enum (PlasticLaw, DamageLaw) dans plasticity et damage ; Python déplie les lois en une fonction chacune, que Rust offre aussi. Les dérogations correspondantes sont enregistrées, avec leur raison, dans tests/python/test_mirror_completeness.py.

ops::matrix — assemblage des matrices

Rust (ops::matrix::…)Python (pyrucast.matrix.…)
stiffness(model: &Model, materials: &ElementField) -> Matrixstiffness(model, materials) -> Matrix
mass(model: &Model, materials: &ElementField) -> Matrixmass(model, materials) -> Matrix
lump(m: &Matrix) -> Matrixlump(matrix) -> Matrix
geometric(model: &Model, materials: &ElementField, stress: &ElementField) -> Matrixgeometric(model, materials, stress) -> Matrix
tangent(model, materials, deformation, prev: &ElementField, dt: Option<f64>) -> Matrixtangent(model, materials, deformation, prev, dt=None) -> Matrix

Les cinq reposent sur assemble_kind(model, materials, kind: MatrixKind, inputs), le moteur commun des assembleurs, qui n’a pas de binding Python.

ops::solver — résolution

Rust (ops::solver::…)Python (pyrucast.solver.…)
solve(matrix: &Matrix, rhs: &NodeField) -> NodeFieldsolve(matrix, rhs, method="lu", relations="lagrange", active_set="schur", cache=True, max_iter=100, tol=1e-10, verbosity="silent") -> NodeField
solve_with_options(matrix: &Matrix, rhs: &NodeField, options: &SolveOptions) -> NodeFieldles options sont les arguments nommés de solve
solve_cancellable / solve_cancellable_with_options (jeton d’interruption en plus)l’interruption (Ctrl+C) est câblée dans solve

La matrice se suffit : ses lignes de relation portent leur sens (SubMatrix::row_sense), aucun modèle n’est passé à la résolution. L’élimination est une option (relations="eliminate"), et l’ensemble actif s’enclenche de lui-même sur une matrice qui porte des inégalités.

ops::export — export vers des formats externes

Rust (ops::export::…)Python (pyrucast.export.…)
write_vtk_mesh(mesh: &Mesh, path: &Path, encoding: VtkEncoding)export_vtk(mesh, path, binary=False) -> None
write_vtk_node_field(mesh: &Mesh, field: &NodeField, path: &Path, encoding: VtkEncoding)export_vtk(mesh, path, field=node_field, binary=False) -> None
write_vtk_element_field(mesh: &Mesh, field: &ElementField, path: &Path, encoding: VtkEncoding)export_vtk(mesh, path, field=element_field, binary=False) -> None
write_vtk_series(mesh: &Mesh, evolution: &Evolution, path: &Path, encoding: VtkEncoding) -> Vec<PathBuf>export_vtk(mesh, path, field=evolution, binary=False) -> None
to_arrays(groups: &[(String, &Mesh)], node_fields: &[&NodeField], element_fields: &[(&ElementField, ElementLayout)], order: NodeOrder, first_tag: i64) -> Exportedto_arrays(groups, *, node_fields=[], element_fields=[], gauss=False, order="pyrucast", first_tag=1) -> dict
vtk_mesh_string / vtk_node_field_string / vtk_element_field_string (le fichier VTK dans une String)—
— (exige l’interpréteur)to_gmsh(meshes, fields=None, *, model_name="pyrucast") -> str
— (exige l’interpréteur)to_medcoupling(meshes, fields=None, *, mesh_name="mesh", gauss=True) -> MEDFileData

spill — le swap utilisateur

Rust (spill::…)Python (pyrucast.…)
stats() -> SpillStats (Unix, feature spill)spill_stats() -> dict

Les clés du dictionnaire sont threshold, count, largest, live et peak, en octets ; threshold vaut None quand rien ne déborde. Sur un build qui ne peut pas déborder (feature absente, ou hors Unix), spill_stats existe quand même : tous les compteurs y valent 0. Voir Calculs plus gros que la RAM.

archive — sauvegarde et relecture d’un graphe

Avec spill_stats, les seuls verbes du niveau racine : ils ne produisent aucun conteneur déterminé, mais un dictionnaire de ce qu’on leur a donné.

Rust (archive::…)Python (pyrucast.…)
save(path, roots: &[(&str, &dyn ArchiveRoot)])save(path, objects: dict) -> None
load(path) -> Objectsload(path) -> dict

À l’écriture les types sont connus du compilateur, d’où la tranche de paires ; à la relecture non, d’où la table nommée dont on tire chaque objet avec son type attendu (objets.mesh("clef")?). Voir Sauvegarde et relecture.

ops::geom héberge locate_points (mapping iso-paramétrique inverse, sous le baignage) et project_points (projection au point le plus proche sur une surface, sous le contact) ; ces deux primitives sont internes (API Rust), pas encore exposées en Python — la seule dérogation de module entier enregistrée dans le garde-fou de complétude (tests/python/test_mirror_completeness.py). ops::coloring (coloriage glouton des mailles) et ops::scatter (scatter dans un motif CSR) sont la machinerie de l’assemblage parallèle : API Rust interne, ni opérateurs ni bindings. Le nœud le plus proche, lui, n’est pas un opérateur : c’est la méthode mesh.nearest_node([x, y]), des deux côtés.

Opérateurs (dunders ↔ traits Rust)

Toutes les classes implémentent __repr__ (← Debug, vue structurelle) et __str__ (← Display, vue résumée façon Cast3M) — voir Conventions. Les autres opérateurs, classe par classe :

Arithmétique sur les champs

L’arithmétique (f + s, f + g, …) existe au niveau zone et au niveau agrégat ; les opérations par composante et entre champs sont portées par les traits SubField / Field. Les dunders +, -, *, /, ** dispatchent selon l’opérande droite : un float déclenche l’arithmétique scalaire, un champ du même type l’arithmétique champ + champ (valeur à valeur).

ClasseOpérateurs / méthodes PythonSémantiqueBacking Rust
SubNodeField / SubElementFieldf + s, f - s, f * s, f / s, f ** sbroadcast scalaire, nouveau champAdd/Sub/Mul/Div<f64>, map_all
SubNodeField / SubElementFieldf + g, f - g, f * g, f / g, f ** gchamp + champ par composante, union/passthrough (même support), nouveau champSubField::merge_components
NodeField / ElementFieldf + s, f - s, f * s, f / s, f ** sbroadcast scalaire sur toutes les zonesField::combine_scalar
NodeField / ElementFieldf + g, f - g, f * g, f / g, f ** gchamp + champ par (support, composante), union/passthroughField::merge_field
NodeField / ElementFieldf + sub, f - sub, … (sub = sous-champ)maj ciblée de la (des) zone(s) de même support (union/passthrough)Field::merge_subfield
zone & agrégatadd_to_component(c, s), sub_/mul_/div_to_componentscalaire sur une composante, en placeSubField/Field::map_component
SubElementFieldset_uniform(c, v)force une composante à vSubField::set_uniform

f + s / f + g renvoie un nouveau champ ; += n’est pas surchargé. La composition de zones n’est pas sur + : c’est l’union | (union en Rust, cf. ci-dessous). L’opérateur + est entièrement réservé à l’arithmétique de champ — scalaire (f + 1.0) et champ + champ valeur à valeur (f + g via merge_components/merge_field) ; p. ex. deux champs constants valant 1 s’additionnent en un champ constant valant 2. Pour fusionner des zones avec vérification (et non additionner) : merge(a, b) ≡ a | b.

Algèbre de Matrix / SubMatrix

Matrix.__mul__ dispatche selon l’opérande droite, comme l’arithmétique de champ ci-dessus : un NodeField déclenche le produit matrice-vecteur (mul_field), un float la mise à l’échelle paresseuse du facteur (voir Matrice creuse). / n’existe que pour le facteur (Matrix n’a pas de division matrice-vecteur), et refuse un diviseur nul (ZeroDivisionError) ou non fini (ValueError). Comme pour les champs, tous ces opérateurs renvoient une nouvelle Matrix — jamais de mutation en place.

+ et - additionnent deux opérateurs ; ils ne sont pas la composition (|, ci-dessous), qui écarte un bloc dont elle tient déjà l’emplacement : k | k vaut k, k + k vaut 2k.

ClasseOpérateurs / méthodes PythonSémantiqueBacking Rust
Matrixk * fieldproduit matrice-vecteur A·x, NodeField neufMatrix::mul_field, Mul<&NodeField>
Matrixk * s, s * k, k / s (s: float)facteur scalaire, blocs clonés dans de nouveaux slots (aucune valeur réécrite), CSR assemblée mise à l’échelle avec, k inchangéeMul/Div<f64> for &Matrix, Mul<Matrix> for f64
Matrix-kfacteur nié, sucre pour k * -1.0Neg for &Matrix
Matrix, SubMatrixa + b, a - bsomme : blocs des deux opérandes, partagés, non dédoublonnés ; résultat non assembléAdd/Sub (toutes combinaisons Matrix/SubMatrix)
SubMatrixb * s, s * b, b / s, -bbloc neuf au facteur ajusté (aucune valeur réécrite)Mul/Div<f64>/Neg for &SubMatrix
SubMatrix.factor (lecture seule)facteur courant du bloc (1.0 par défaut)SubMatrix::factor

Indexation par clé

ClasseOpérateurs PythonCléBacking Rust
SubNodeFieldf[nid, "c"], f[nid, "c"] = v(NodeId, composante)Index/IndexMut<(NodeId, &str)>
SubElementFieldf[cell, g, "c"], f[cell, g, "c"] = v(maille, point de Gauss, composante)méthodes value / set_value (pas de trait Index)

Protocole séquence — len(x), x[i], for _ in x

Classelen(x)x[i] →Backing Rust
Cellnombre de nœudsNodeméthodes
SubMeshnombre de maillesCellméthodes
Meshnombre de sous-maillagesSubMeshAggregate (macro)
SubFiniteElementSpacenombre d’élémentsElementméthodes
FiniteElementSpacenombre de sous-espacesSubFiniteElementSpaceAggregate (macro)
NodeFieldnombre de sous-champsSubNodeFieldAggregate (macro)
ElementFieldnombre de sous-champsSubElementFieldAggregate (macro)
Modelnombre de sous-modèlesSubModelAggregate (macro)
Matrixnombre de sous-matricesSubMatrixAggregate (macro)
SubMatrixnombre d’entrées posées une à une (add_entry) ; 0 pour un bloc assemblé par éléments—méthode entry_count
Evolutionnombre de sous-évolutionsSubEvolutionAggregate (macro)
SubEvolutionnombre d’échantillons tabulés—méthode __len__

Union | (composition d’agrégats)

La composition d’agrégats est l’union : côté Python elle s’écrit | (comme set | set), côté Rust ce sont les méthodes nommées union / union_sub / union_sub_first / union_subs (renvoient Result<…>). Les sous-objets sont partagés (refcount), jamais copiés ; les contraintes de domaine (même Coords pour Mesh, etc.) restent vérifiées.

Sémantique d’union (uniforme pour tous les agrégats) :

  1. Déduplication par handle : un sous-objet dont le Handle désigne un objet déjà présent (cf. Handle::same_object) n’est pas ajouté deux fois.
  2. Finalisation (Aggregate::finalize) : par défaut un no-op ; les champs la surchargent pour fusionner les zones partageant un même support (voir plus bas).
PythonRustRésultatSémantique
agrégat | agrégata.union(&b)agrégatunion dédupliquée, ordre de 1ʳᵉ apparition
agrégat | suba.union_sub(&h)agrégatajoute un sous-objet en queue (ignoré si déjà présent)
sub | agrégata.union_sub_first(&h)agrégatla même union, sous-objet en tête (via __ror__)
sub | subT::union_subs(&a, &b)agrégatunion des deux sous-objets
node | nodea.union(&b)Meshmaillage POI1 unitaire sur les deux nœuds
mesh | nodem.union_node(&n)Meshajoute un point (erreur si Mesh non unitaire POI1)

Vaut pour les sept agrégats (Mesh, FiniteElementSpace, Model, Matrix, NodeField, ElementField, Evolution) plus Node.

Finalisation des champs (fusion par support)

Après l’union par handle, NodeField et ElementField fusionnent les sous-champs définis sur le même support (même Handle<SubMesh> pour NodeField, même Handle<SubFiniteElementSpace> pour ElementField) :

  • le sous-champ fusionné porte l’union des composantes ;
  • une composante définie par plusieurs sous-champs doit y avoir la même valeur partout (comparaison exacte), sinon | lève une erreur ;
  • pour NodeField, une vérification inter-supports finale impose qu’un nœud partagé par des zones de supports différents s’accorde sur toute composante commune.

Ces opérations sont aussi exposées en Rust : ops::node_field::consolidate (NodeField) et ops::element_field::consolidate (ElementField).

+ est réservé à l’arithmétique de champ

L’opérateur + (et -, *, /, **) est l’arithmétique de champ : scalaire (subfield + 2.0 ajoute la valeur à chaque composante) et champ + champ, valeur à valeur (f + g, cf. plus haut). Il n’est jamais utilisé pour composer des agrégats — c’est | qui s’en charge, sans collision.

Documentation et tests

Cette page dit quel type d’exemple vit où, ce que chaque type de test prouve, et surtout qui le vérifie. Les règles seules, sans la narration, sont dans CONVENTIONS.md à la racine du dépôt.

Pourquoi une page entière

Parce que le projet s’est fait prendre trois fois, de la même façon.

Une doc qui a menti pendant tout un redécoupage. Le remaniement d’API du 3 août 2026 a renommé ops::assemble en ops::matrix, ops::build en ops::element_field, ops::mesher en ops::mesh. Trois pages du book (model.md, matrix.md, thermique.md) sont restées sur les anciens noms. La suite de tests était verte, la CI aussi, et personne ne l’a vu pendant des semaines — parce que rien, nulle part, ne lisait ces pages.

Un vérificateur qui ne vérifiait rien. mdbook test tournait à chaque check, égrenait 85 chapitres et affichait un verdict. Il testait zéro bloc : les 73 clôtures Rust du book étaient toutes marquées rust,ignore, et ignore veut dire exactement « ne pas compiler ». Le pas était vert parce qu’il ne regardait rien — il a depuis été retiré de la chaîne.

Un garde-fou aveugle, deux fois de suite. L’outil écrit pour compenser est passé au vert alors qu’il ne vérifiait rien : d’abord parce que le fichier qu’il produisait n’était pas analysable — et un fichier qui ne parse pas est vérifié pour rien —, ensuite parce qu’il filtrait sur une liste de codes d’erreur fatals, ce qui laissait passer les erreurs de syntaxe, qui n’ont pas de code. Dans les deux cas il fallait casser volontairement ce qu’il surveillait pour s’en apercevoir.

D’où la ligne directrice de toute cette page :

Un pas de vérification qui passe au vert sans rien regarder coûte plus cher que son absence : il se lit comme de la couverture.

Les types de test

typeoùce qu’il prouvelancé par
test unitaire#[cfg(test)] mod tests dans src/**.rsle comportement d’une unité, publique ou privéecargo test — check_rust
doctestcommentaires /// et //! dans src/**.rsque l’exemple documentant un item compile et tournecargo test --features viz — check_rust
test d’intégrationtests/*.rsune chaîne complète vue de l’extérieur du cratecargo test — check_rust
test Pythontests/python/*.pyla surface pyo3 et le comportement côté Pythonpytest — check_python
garde-foutests/python/test_method_exposure.py, test_mirror_completeness.pyune invariante d’API, pas un comportementpytest — check_python
exempleexamples/*.pyune chaîne utilisateur de bout en boutrun_examples — check_examples
script du tutorieltutoriel/*.pyun parcours pédagogique completrun_examples — check_examples
bancbenches/*.rsune performance, jamais une correctioncargo bench, hors CI

Trois distinctions valent d’être tenues :

  • Un test unitaire n’est pas un test d’intégration. Le premier a accès au privé et teste une unité ; le second voit le crate comme un utilisateur, par son API publique seulement. Un comportement qui n’est prouvé qu’en unitaire peut être inatteignable de l’extérieur.
  • Un garde-fou n’est pas un test. Il ne vérifie aucun calcul : il vérifie qu’une règle tient encore — que tout opérateur Rust a son binding Python, que tout verbe éligible a sa méthode. Il échoue quand quelqu’un a oublié une projection, pas quand un résultat est faux.
  • Un banc ne remplace jamais un test. Il mesure, il n’affirme rien, et il ne tourne pas en CI. Le dimensionner autour de 0,5 s par itération : en dessous, le bruit de mesure atteint ±18 % et noie le signal ; au-dessus, la mémoire s’envole. Et prendre la référence en basculant de commit avec git, pas de mémoire — puis relancer, pour trier ce qui est du bruit de ce qui est un écart.

Enfin, un test qui écrit sur disque efface ce qu’il a écrit, qu’il passe ou non :

  • côté Rust, un test d’intégration prend un TempDir du module common (tests/common/mod.rs, déclaré par mod common;). C’est un dossier à lui seul, supprimé quand la valeur tombe, y compris pendant le déroulement d’un test en échec ;
  • côté Python, une fonction de test prend tmp_path, et un fichier exécuté au niveau module prend tempfile.TemporaryDirectory() (voir plus bas).

Un nom fabriqué à la main sous std::env::temp_dir(), ou un mkdtemp(), survit au test. En dix-sept jours, ces noms avaient laissé quinze cents dossiers dans un /tmp qui vit en mémoire vive.

Où vit un exemple

C’est l’arbre de décision pratique. On part de ce que l’exemple illustre, jamais de la page où on veut l’afficher.

l’exemple illustre…il vit…le book le montre par…vérifié par
un item de l’API Rustun doctest sur l’itemrien — il est dans la rustdoccargo test --doc
une chaîne Rust complète (une physique)tests/<sujet>.rs, entre ancresun include ancrécargo test
une chaîne Python complèteexamples/<sujet>.pyun include, entier ou ancrérun_examples
un parcours pédagogiquetutoriel/<sujet>.py, entre ancresun include ancrérun_examples
l’usage d’un opérateur en Pythontests/python/test_doc_<famille>.py, entre ancresun include ancrépytest
ce qui n’est pas du code (signature annotée, pseudo-code)la page elle-même```text — pas de coloration, donc pas de promesserien, et c’est assumé

Un piège vérifié à la première migration : le préfixe test_ n’est pas décoratif. pytest ne collecte que test_*.py ; un fichier de sources d’exemples nommé doc_ops_assemblage.py est inclus dans le book et exécuté par personne. Le garde-fou includes ne peut pas le voir — l’ancre résout très bien —, et on retombe sur une page qui montre du code que rien ne vérifie.

Au niveau module, pas dans une fonction

mdbook n’enlève pas l’indentation d’un extrait inclus. Un bloc ancré à l’intérieur d’une fonction de test s’affiche donc décalé de quatre espaces, sur chaque page, indéfiniment — ce qu’aucun utilisateur n’écrirait. Le code ancré vit par conséquent au niveau module, et le fichier se lit dans l’ordre de la page, les variables coulant d’une ancre à la suivante, comme elles le font pour le lecteur du chapitre.

Trois conséquences, toutes mesurées :

  • pytest exécute le fichier à la collecte. Un exemple qui casse est une erreur de collecte et non un test en échec : le traceback est complet, la réécriture d’assertion fonctionne (assert 450 == 451), et le code de retour vaut 1. --continue-on-collection-errors, posé dans pyproject.toml, évite qu’elle interrompe le reste de la suite ;
  • ces fichiers ne comptent plus de « tests » au sens de pytest. C’est un changement d’affichage, pas de couverture : le code s’exécute et les assertions mordent ;
  • les fixtures ne sont pas disponibles. Ce que tmp_path + monkeypatch.chdir donneraient — écrire des fichiers sous des noms courts sans polluer le dépôt — s’obtient en trois lignes au niveau module : tempfile.TemporaryDirectory(), un os.chdir en tête, et surtout un os.chdir de retour en fin de fichier. Omettre la restitution déplacerait tous les fichiers de test collectés ensuite. C’est le procédé de sauvegarde.md, installation.md et visualization.md.

Doctest et bloc du book ne se confondent pas

C’est la première question qu’on se pose, et la réponse est : ce sont deux choses différentes, on ne cherche pas à les unifier.

Le doctest sert le lecteur de la rustdoc. Il est à côté de la fonction, il répond à « comment j’appelle celle-ci ? », il tient en cinq lignes, et son montage se cache derrière # . Il suit l’item : si la signature change, il casse.

Le bloc du book sert le lecteur du chapitre. Il répond à « comment je monte un calcul thermique ? », il fait quarante lignes, et il n’a de sens que dans l’ordre du récit. Il vit dans un test d’intégration, où il est exécuté pour de vrai.

Deux publics, deux sources. Une chaîne complète recopiée en doctest serait illisible ; un doctest promu en chapitre ne raconterait rien.

include ou rustdoc_include

Le mécanisme d’inclusion se choisit sur une seule question : est-ce que quelque chose compile déjà ce fichier ?

  • Oui (tests/, examples/, tutoriel/, tests/python/, src/) → l’inclusion simple suffit. La vérification a lieu à la source ; mdbook ne fait que l’afficher.
  • Non → rustdoc_include, qui passe le fichier entier à rustdoc tout en n’affichant que l’ancre, de sorte que mdbook test le compilerait. Il faudrait alors le remettre dans check_doc, d’où il a été retiré.

Il n’y a aujourd’hui aucun fichier du second cas dans ce dépôt, et il n’y a pas de raison d’en créer : un fichier d’exemple qui mérite d’être compilé mérite d’être un test.

La règle d’or

Aucune page du book ne possède de code.

Toute clôture ```rust ou ```python d’une page contient une directive d’inclusion pointant une source que la CI exécute. Sans exception : ce qui ne peut pas s’exécuter n’est pas du code, et se balise ```text.

Le corollaire pratique : quand on veut ajouter un exemple à un chapitre, on n’écrit pas dans le chapitre. On écrit un test, on l’encadre d’ancres, et on l’inclut. L’exemple devient de la couverture au lieu d’être une dette.

État au 18 août 2026 :

surfaceconformeécrit à la main
blocs Rust du book690
blocs Python du book1880

C’était 22 sur 73 et 61 sur 190 quand ces conventions ont été écrites. Le garde-fou fences empêche désormais d’en réintroduire.

Les doctests

Tout item de l’API publique porte un exemple exécutable. C’est le point 3 de la Definition of Done, et c’est l’étage le moins cher de tous : dans un doctest, le crate est dans la portée automatiquement, cargo gère l’édition de liens, il n’y a ni chemin de bibliothèque à passer, ni ancre à maintenir. On écrit l’exemple à côté de la fonction, et cargo test le vérifie.

Le vocabulaire des attributs, et lequel choisir :

Une exception, et une seule : la méthode de pure délégation. Elle expose la face « sujet » d’un opérateur — aucune logique, un appel à la fonction libre — et c’est cette dernière qui est la forme canonique et qui porte la documentation. Y ajouter un exemple dupliquerait le sien : soixante-treize exemples de plus, plus de six cents lignes dans des fichiers dont tout l’objet est de tenir en une ligne par verbe, et un second texte à faire vieillir.

On les reconnaît à leur marqueur, non à leur emplacement : toute leur documentation tient en un « voir [module::verbe] ». Chercher par répertoire en laissait passer la moitié — il y en a au bas de src/ops/matrix.rs, et d’autres que produit une macro sur impl $T.

attributeffetquand
(aucun)compile et exécutele cas normal, à viser toujours
no_runcompile, n’exécute pasouvre une fenêtre, dure une minute, écrit un fichier
compile_faildoit échouer à compilerdocumenter ce que le typage interdit
should_panicdoit paniquerdocumenter une précondition
ignorene fait rien❌ proscrit

ignore est proscrit sans exception : c’est le marqueur qui ne vérifie rien, et c’est précisément lui qui a désarmé le book 73 fois. S’il paraît nécessaire, c’est que no_run était le bon choix, ou que l’exemple appartient à un test.

Deux points utiles :

  • les lignes de montage se cachent avec # en tête. Elles n’apparaissent pas dans la rustdoc, mais elles sont compilées — c’est ce qui permet à un exemple de trois lignes lisibles de reposer sur dix lignes de Coords, de nœuds et de maillage ;
  • les doctests s’exécutent aussi sur les items privés. La visibilité n’est donc jamais une raison de s’en passer.

Ce qui est vérifié, et par quoi

règlegarde-fouoù
tout opérateur Rust a son binding Pythontest_mirror_completeness.pycheck_python
tout verbe éligible a sa méthodetest_method_exposure.pycheck_python
les exemples et le tutoriel tournentrun_examplescheck_examples
les doctests compilent et tournentcargo test --doccheck_rust
la rustdoc n’a aucun lien cassécargo doc avec RUSTDOCFLAGS="-D warnings"check_doc
les includes du book résolventdoc_lint.py includescheck_doc
aucune page ne possède de codedoc_lint.py fencescheck_doc
la prose ne cite pas de symbole disparudoc_lint.py symbolescheck_doc
tout item public a un exempledoc_lint.py doctestscheck_doc
toute entrée Python est citée par un exempledoc_lint.py api-pythoncheck_doc
chaque équation arrive intacte à MathJaxdoc_lint.py equationscheck_doc

Le détail de ce que chaque script lance — commande par commande, avec les fichiers concernés et les durées mesurées — est dans Compilation et tests.

Les six derniers vivent dans script/doc_lint.py et se lancent aussi un par un : python script/doc_lint.py includes. Chacun porte son registre de dérogations — nom → raison — et son test d’hygiène, qui échoue sur une entrée périmée.

Quatre d’entre eux méritent une précision.

Le cliquet de couverture. La règle « tout item public porte un exemple » ne pouvait pas être vérifiée frontalement au départ : la dette était de 1531 items sur 1542, et un garde-fou rouge dès le premier jour finit désactivé. Le registre script/doc_coverage.txt liste donc les items qui n’en ont pas, et le garde-fou échoue dans deux cas : un item public absent du registre qui n’a pas d’exemple — donc tout item nouveau —, et un item du registre qui en a désormais un sans avoir été retiré. La liste ne pouvant que fondre, sa fonte a servi d’indicateur d’avancement jusqu’à ce qu’elle atteigne zéro, le 2026-08-19. Le registre reste en place, vide : c’est lui qui refuse un item public nouveau sans exemple. On le régénère avec python script/doc_lint.py --ratchet.

Sur ce chemin, le cliquet lui-même a dû apprendre trois choses. Un item n’est pas de nous parce qu’il figure dans all.html : les impls génériques de nalgebra et d’either, et les traits réexportés de rayon, y entrent sans être de l’API du crate — le garde-fou les écarte en bornant l’extraction et en exigeant un lien « source » vers src/pyrucast/. Et deux chemins peuvent désigner le même item : rustdoc nomme le doctest d’un impl paramétré CellGeom<'a>::det_j_w, et fait passer un réexport par son chemin d’origine — d’où une normalisation des paramètres et une comparaison par sous-suite de segments. Chacune de ces corrections a été vérifiée en cassant volontairement le garde-fou.

Le cliquet Python est le pendant du précédent, à une granularité plus lâche, et le compromis mérite d’être dit. Les docstrings Python sont écrites dans les /// de src/py/ et le module est compilé : le DocTestFinder de la bibliothèque standard ne descend pas dans les fonctions d’un module d’extension — sur pyrucast.mesh il ne trouve que le module lui-même. Un doctest par item y demanderait donc un collecteur maison. On exige à la place qu’une entrée publique soit citée par un exemple exécuté du book, lu par AST pour qu’un nom en commentaire ou dans une chaîne ne compte pas. La granularité est le nom, non l’appel : .get( cité une fois vaut pour les get de tous les conteneurs. C’est assumé, et cela garantit ce qu’on cherchait — aucune entrée publique absente des exemples, et aucune entrée nouvelle qui entre sans être montrée. Registre : script/python_coverage.txt, régénéré par python script/doc_lint.py --ratchet-python.

Les équations passent par Markdown avant MathJax, et Markdown les abîme sans un mot : il mange la barre de \{ ou de \,, lit une ligne = comme le soulignement d’un titre et une ligne - … comme une puce, change une prime en apostrophe courbe. En octobre 2026, 99 équations sur 1181 étaient ainsi abîmées sur le site publié, dont 25 écrites \( … \) avec une seule barre et rendues en texte brut. Le garde-fou compare chaque équation du source, échappements Markdown appliqués, au TeX que MathJax trouve dans le HTML construit — d’où l’ordre de check_doc, où mdbook build passe avant doc_lint.py. Il signale en plus, sur le source seul, les commandes dont Markdown mange la barre et les lignes qu’il lirait comme un bloc : ces dernières dépendent de la version de mdBook (les listes de définition ne datent que de la 0.5), et le HTML d’une autre version ne les montrerait pas. Les règles d’écriture sont dans CONVENTIONS.md.

L’audit de la prose ne lit que les passages en code inline, hors blocs : c’est ce qui écarte les noms de fichiers et les domaines, qui ressemblent à des chemins. Il vérifie chaque segment, y compris le dernier — le découper en paires laisserait ops::matrix::stiffness sans contrôle sur le nom qui bouge le plus souvent. Et pour un Type::membre, il lit la rustdoc, qui seule connaît l’appartenance réelle : un nom qui existe ailleurs dans le crate ne suffit pas à valider la citation. C’est ce qui a fait tomber quatre erreurs d’un coup, dont un Coords::acquire qui n’a jamais existé — en Rust le verbe est sur Node, c’est côté Python qu’il est sur le magasin.

Ce qui n’est pas vérifié, et pourquoi

Deux zones sont hors d’atteinte, et il vaut mieux les nommer que laisser croire à une couverture totale.

Ce qui n’est pas du code. Une signature annotée de /* … */, une énumération abrégée par // … une ligne par physique, un pseudo-code f(...) : ces blocs se balisent ```text. La coloration syntaxique mentirait, et le lecteur voit ainsi tout de suite qu’il ne peut pas les copier. Ce n’est pas une dérogation à la règle d’or — c’est reconnaître que ces blocs ne relèvent pas d’elle.

La nuance se juge bloc par bloc, jamais page par page. Une première version de ces conventions déclarait trois « pages d’esquisses » entières ; à les regarder de près, elles contenaient aussi la déclaration de Handle<T>, celle du trait Cancel et l’implémentation PySignals — du code réel, recopié d’un source qui existe, et qui pourrissait comme le reste. Ces blocs sont désormais inclus depuis src/, et la notion de page d’esquisses a disparu.

La prose. Un paragraphe qui cite ops::matrix::stiffness en texte courant n’est couvert par aucun include. C’est là qu’étaient trois des erreurs trouvées en août 2026. Le seul filet possible est un audit qui extrait les symboles cités et les résout contre le crate et le module Python installé — c’est le quatrième garde-fou de la table ci-dessus.

Installer un garde-fou

Une règle de méthode, née de deux échecs consécutifs :

Un garde-fou n’est installé qu’une fois cassé volontairement, au moins une fois, code de retour vérifié — pas seulement l’affichage.

Concrètement : on renomme une méthode dans la source qu’il surveille, on lance le garde-fou, et on vérifie qu’il sort en échec. Puis on remet en état. Sans cette étape, on ne sait pas si le vert signifie « tout va bien » ou « je ne regarde rien ».

Deux pièges mesurés dans ce dépôt, qui donnent la mesure du risque :

  • une ancre d’inclusion inexistante produit un bloc de code vide, un code de retour 0, et aucun message : la page perd son exemple en silence ;
  • un fichier inclus absent ne produit qu’un [ERROR] dans le log, avec un code de retour 0 lui aussi.

Autrement dit, le mécanisme sur lequel repose toute la stratégie n’est, à ce jour, gardé par rien.

Et une règle de forme : toute dérogation porte une raison. Dette de migration, item sans exemple, exclusion d’un garde-fou — chacune vit dans un dictionnaire nom → raison, accompagné d’un test d’hygiène qui échoue si l’entrée devient périmée. C’est le motif déjà en place dans test_method_exposure.py et test_mirror_completeness.py ; il n’en est pas créé d’autre.

Où en est la mise au propre

Les deux chantiers sont terminés.

La migration du book : 257 blocs, aucun écrit à la main — 188 Python, 69 Rust. Le registre DETTE_MIGRATION de script/doc_lint.py est vide, et le garde-fou fences empêche qu’il se repeuple.

Les doctests : script/doc_coverage.txt est vide. Tout item public porte un exemple exécutable, et le cliquet refuse désormais qu’un item nouveau entre sans le sien. cargo test --doc en exécute un peu plus de neuf cents.

Une partie de la dette apparente n’en était pas une, et c’est le fait le plus utile de l’exercice : sur les ~1530 items du départ, un tiers environ n’aurait jamais dû figurer dans l’API publique — machinerie de mailleur pub par défaut plutôt que par intention, méthodes de bibliothèques tierces entrées par impl générique ou par pub use rayon::prelude::*. Écrire les exemples a servi d’audit de la surface publique autant que de documentation.

Et écrire ces exemples a corrigé la documentation en une trentaine d’endroits, chaque fois dans le même sens : l’assertion disait ce que le code fait, la prose disait autre chose. Quelques-uns valent d’être retenus — une symétrie réinverse la connectivité de ses mailles, mask rend un indicateur 0/1 et non un champ filtré, to_poi1 dédoublonne par zone et non entre zones, et Drucker-Prager, au sommet de son cône, laisse p à zéro pendant que ε_p gonfle en volume.

Modèle mémoire

En Python, la mémoire de pyrucast se gère seule : on ne libère rien, on ne verrouille rien. Quatre comportements sont à connaître :

  • Un objet est partagé, pas copié. Une affectation ou une zone lue par m[i] désignent le même objet, une union | reprend les zones sans les copier. Un opérateur (+, restrict, solve…) rend un objet neuf.
  • Un objet vit tant que quelque chose le tient : une variable Python ou un autre objet pyrucast. Un champ tient son support, un maillage ses zones, une zone son Coords. Ce que plus rien ne tient est libéré aussitôt.
  • Un nœud reste dans son Coords tant que le Coords existe.
  • Certains résultats sont gardés en cache par l’objet dont ils sont tirés, et resservent aux calculs suivants.

Partager plutôt que copier

import pyrucast

c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
d = c.add_node([2.0, 0.0])
gauche = pyrucast.mesh.line(a, b, 2)
droite = pyrucast.mesh.line(b, d, 2)
tout = gauche | droite  # les deux zones, sans copie
print(gauche)  # Mesh: 1 submesh(es) [#8baac0], 2 cell(s) total
print(tout)  # Mesh: 2 submesh(es) [#8baac0, #993d70], 4 cell(s) total

T = pyrucast.NodeField(gauche, ["T"])
U = T  # un second nom pour le même champ
U[0].set_uniform("T", 5.0)
print(T.value(a, "T"))  # 5.0 : T voit l'écriture faite par U

S = T + T  # un opérateur rend un objet neuf
S[0].set_uniform("T", 1.0)
print(T.value(a, "T"), S.value(a, "T"))  # 5.0 1.0

print d’un agrégat affiche le repère de chaque zone (#8baac0) : gauche et tout montrent le même, ils partagent cette zone. U = T ne copie rien : écrire par U modifie T. T + T rend un champ neuf, qu’on modifie sans toucher T.

Une zone partagée ne change pas sous les objets qui l’indexent : dès qu’un espace éléments finis, un champ ou une matrice s’y pose, elle est scellée et sa connectivité ne bouge plus (voir Scellement). Pour repartir d’une zone sans la partager, on la copie avec mesh.duplicate() ou pyrucast.mesh.copy.

Durée de vie

print(c.refcount(a.id))  # 3 : la variable a, sa maille dans gauche, le support de T
del gauche, droite, tout  # plus rien ne tient les zones SEG2
print(c.refcount(a.id))  # 2 : la maille est partie avec sa zone
print(T[0].support())  # SubMesh<POI1>: 3 cell(s) — T tient toujours son support

Après le del, plus rien ne tient les zones SEG2 : elles sont libérées aussitôt et rendent leurs nœuds. Le champ T tient son support, le nuage des nœuds de gauche, qui reste.

coords.refcount(id) compte ce qui utilise un nœud : les Node tenus et chaque maille qui le contient (voir Coordonnées). Un nœud n’est jamais retiré de son Coords : sa place et ses positions y restent même quand plus rien ne l’utilise, et leur mémoire n’est rendue qu’avec le Coords.

Ce qui est mis en cache

Un cache est tiré d’un objet et gardé par lui. Il se calcule à la première demande, resert tant que l’objet ne change pas, et disparaît avec lui. Aucun n’est écrit dans une sauvegarde : après relecture, chacun se recalcule à la première demande. En Python, un cache ne se voit qu’au temps de calcul : le premier appel le construit, les suivants s’en servent.

Gardé parCacheUtilité
une zone de maillageson nuage de nœuds (une zone POI1)les champs posés sur un même maillage reçoivent le même support et se combinent nœud à nœud — Maillage
une zone de maillagela table « nœud → ligne »lire ou écrire la valeur d’un nœud sans le chercher — Maillage
un sous-espace éléments finisla coloration des maillesassembler et accumuler aux nœuds en parallèle, sans conflit d’écriture — Espace éléments finis
un modèlele motif de chaque genre de matriceréassembler (une itération de Newton) sans reconstruire la structure de la matrice — Modèle
une matricesa factorisationrésoudre d’autres seconds membres sans refactoriser — Solveur

Les matrices d’un même genre assemblées sur un même modèle partagent en plus leur structure (quels termes existent, et où) : chacune ne garde en propre que ses valeurs.

Sauvegarde

Une sauvegarde écrit les objets demandés et tout ce dont ils dépendent ; la relecture rend le même partage : deux champs posés sur un support restent, relus, deux champs sur un seul support. Les caches et les compteurs ne sont pas écrits, ils se recalculent. Le mode d’emploi est au chapitre Sauvegarde et relecture, la mécanique plus bas.

Calculs plus gros que la RAM

Sous Linux et macOS, la roue Python peut placer ses grosses allocations dans des fichiers que le système écrit sur disque quand la mémoire manque : un swap, sans droit root. On l’active avant de lancer Python par la variable d’environnement PYRUCAST_SPILL_DIR, et pyrucast.spill_stats() dit ce qui a débordé. Voir Calculs plus gros que la RAM.

Côté Rust

Rust n’a pas de ramasse-miettes : chaque valeur a un propriétaire, et elle est détruite quand celui-ci disparaît (fin du bloc, ou destruction de la structure qui la contient). Pour qu’un maillage serve à plusieurs champs, il faut donc un outil explicite : Handle<T>.

Handle<T> : un objet partagé

///
/// ```
/// # use pyrucast::handle::Handle;
/// # use pyrucast::coords::Coords;
/// let h1 = Handle::new(Coords::new(2).unwrap()); // premier ticket
/// let h2 = h1.clone(); // même objet
/// assert!(h1.same_object(&h2));
/// drop(h1); // h2 tient encore l'objet
/// assert_eq!(h2.read().dim(), 2);
/// ```
pub struct Handle<T> {
    cell: Arc<RwLock<T>>,
}

Un Handle assemble deux briques :

  • Arc tient un compteur de propriétaires. Cloner un Arc ne copie pas l’objet, cela augmente le compteur ; quand le dernier Arc disparaît, l’objet est détruit.
  • RwLock (de la bibliothèque parking_lot) est un verrou lecture/écriture : plusieurs lecteurs à la fois, ou un seul écrivain.

Chaque objet Python enveloppe un Handle, ou pour un agrégat la liste des handles de ses zones : une variable Python est un propriétaire comme un autre.

#[test]
fn un_handle_est_un_ticket() {
    let h1 = Handle::new(Coords::new(2).unwrap()); // premier ticket
    let h2 = h1.clone(); // même objet
    assert!(h1.same_object(&h2));
    drop(h1); // h2 tient encore l'objet
    drop(h2); // dernier ticket → l'objet est détruit
}

Il en découle :

  • La libération est automatique. Quand le dernier handle disparaît, la fonction de destruction de l’objet (Drop) s’exécute, une fois : c’est là qu’une zone de maillage rend ses nœuds au Coords.
  • Aucune référence n’est périmée. Tenir un handle garde l’objet en vie : read et write ne peuvent pas échouer.
  • L’identité, c’est l’adresse. same_object dit si deux handles désignent le même objet. L’union | s’en sert pour ne pas ajouter deux fois une zone, un champ pour reconnaître son support.
  • Il n’y a pas de cycle. Un objet ne désigne que ce sur quoi il est construit (un champ son support, une zone son Coords), jamais ce qui le tient. Le comptage suffit donc à tout libérer.

L’enveloppe porte ses propres méthodes (read, write, same_object) et n’expose pas celles de l’Arc qui permettraient de contourner le verrou.

Lire et écrire : les guards

#[test]
fn un_guard_par_objet() {
    let handle = Handle::new(Coords::new(2).unwrap());

    let coords = handle.read(); // verrou lecture sur CET objet seul
    println!("dim = {}", coords.dim()); // coords se comporte comme un &Coords
    drop(coords); // (ou fin de portée) → verrou relâché

    handle.write().add_node(&[0.0, 0.0]).unwrap(); // verrou écriture, le temps de l'appel
}

read() et write() rendent un guard : la preuve qu’on tient le verrou. Il s’utilise comme une référence vers l’objet et rend le verrou quand il disparaît, à la fin du bloc ou par drop : on ne peut pas oublier de déverrouiller.

Un guard tient lui-même l’objet en vie : une fonction peut le rendre, une structure peut le garder. C’est ainsi que FieldView garde ouvertes les zones d’un champ, et que les opérateurs lisent valeurs et connectivités en place, sans les recopier (voir Zéro-copie).

Deux règles de verrouillage

Le verrou couvre un seul objet : deux threads qui travaillent sur des objets différents ne s’attendent jamais, et plusieurs threads lisent le même maillage pendant un assemblage. Deux règles, que le compilateur ne vérifie pas, évitent que deux attentes se bloquent l’une l’autre pour toujours :

  1. Ne pas reprendre un objet qu’on tient déjà, même en lecture. Le verrou ne reconnaît pas le thread qui le tient : un second read() attend comme n’importe qui, et si un écrivain s’est mis en attente entre les deux, le second lecteur attend l’écrivain, qui attend le premier lecteur.
  2. Verrouiller une zone avant son Coords, jamais l’inverse.

Python n’est pas concerné : chaque appel prend et rend ses verrous avant de revenir.

Afficher un handle

Afficher un handle donne <SubMesh #7f3a2c> : le type et la fin de l’adresse (ses 24 bits de poids faible), le repère que print montre en Python. L’affichage ne lit pas l’objet, il ne bloque donc jamais, même pendant qu’un guard en écriture est tenu. Le repère distingue les objets vivants au même instant ; une adresse libérée sert à nouveau, ce n’est donc pas une identité durable.

Le compteur par nœud

Un nœud n’est pas un objet : c’est un numéro, NodeId (4 octets), qui désigne une ligne des tableaux du Coords. Un handle par nœud coûterait un pointeur compté par occurrence dans les connectivités — deux millions pour 100 000 HEX20 — et disperserait en mémoire un tableau contigu. Le Coords tient donc un compteur par nœud, un Vec<u32> à côté des positions : un Node l’augmente à sa création et le diminue à sa destruction, une zone de maillage fait de même pour chaque occurrence de ses mailles.

Les caches : OnceLock

Un OnceLock est une case qui se remplit une fois. Le premier qui la lit la calcule, les suivants lisent le résultat ; si deux threads la calculent en même temps, un seul résultat est gardé. La remplir ne demande qu’un accès en lecture à l’objet, un cache se construit donc sous un read(). La vider demande un accès en écriture : c’est ce que font les opérations qui modifient l’objet.

ObjetMembreTypeVidé par
SubMeshpoi1_companionOnceLock<Handle<SubMesh>>toute modification de la connectivité
SubMeshnode_indexOnceLock<HashMap<NodeId, usize>>toute modification de la connectivité
SubFiniteElementSpacecoloringOnceLock<Vec<Vec<usize>>>rien : les zones d’un espace sont scellées
Modelmatrix_patternsun OnceLock<Arc<AssemblyPattern>> par genre de matriceadd_sub, add_subs
MatrixfactorizationMutex<Option<…>>tout changement de la matrice

La factorisation est dans un Mutex, un verrou simple, plutôt que dans un OnceLock : le solveur peut la remplacer. Tous ces membres portent #[serde(skip)], ce qui les exclut de la sauvegarde.

Ce qui ne change plus se partage sans verrou, par un Arc seul : le motif d’un modèle, et la structure des matrices assemblées sur lui (numérotation, position des termes). Une matrice assemblée ne possède que ses valeurs.

Sauvegarde : un handle devient un numéro

Le trait Portable (serde + bincode) fixe les octets d’un objet, les mêmes sous Linux et Windows (voir Sérialisation).

Un handle est une adresse, qui n’a pas de sens dans un autre processus. Ce qui part sur le disque est donc un numéro propre au fichier ; la table qui traduit l’un en l’autre n’existe que pendant un save ou un load, et sérialiser un handle hors de là est une erreur.

Rien ne déclare ce qu’un objet référence : c’est la sérialisation d’un handle qui le découvre.

déjà vu ?  → écrire son numéro, fini
sinon      → réserver un numéro
             sérialiser l'objet dans son propre enregistrement
                (ce qui découvre, récursivement, ses propres dépendances)
             déposer l'enregistrement
             écrire le numéro

Ajouter un membre Handle à une structure suffit à le faire sauvegarder, sans liste à tenir à jour ailleurs. Les enregistrements sortent dans l’ordre des dépôts, toute dépendance avant ce qui la référence : la relecture est une simple boucle. Rencontrer un numéro réservé mais pas encore déposé signalerait un cycle ; l’écriture s’arrête alors sur une erreur qui nomme l’objet.

À la relecture, chaque objet passe par on_load avant d’être rendu : c’est là qu’il reconstruit ce qui n’est pas écrit. Les compteurs des objets se refont seuls, chaque handle relu en étant un. Ceux des nœuds repartent de zéro : le Coords relu les remet à zéro, puis chaque zone de maillage relue compte ses occurrences.

Parallélisme

pyrucast parallélise ses boucles de calcul lourdes (assemblage par élément, intégration du comportement par point de Gauss, mathématiques de champ, export VTK, solveur) sur les cœurs CPU avec rayon. Le parallélisme est toujours actif ; le nombre de threads suit RAYON_NUM_THREADS.

Le parallélisme est porté au-dessus des noyaux

Principe central : un noyau de physique ne voit jamais rayon, un handle, ni un verrou. La parallélisation vit dans une couche au-dessus, de sorte qu’ajouter une physique ou un opérateur revient à écrire des mathématiques séquentielles pures.

  • Le module parallel ré-exporte le prelude rayon et fixe la politique de grain (MIN_PARALLEL_LEN, via with_min_len) : les petits problèmes restent effectivement séquentiels, sans surcoût de threads.
  • Les drivers de models::kernel (element_pointwise, nodal_pointwise, assemble_block) sont les seuls porteurs de rayon côté physiques. Ils tiennent les guards de lecture, parallélisent par cellule et appellent un noyau pur fourni par la physique. Voir Ajouter une physique.

Ce qu’un noyau au point d’intégration n’a pas le droit de faire

Un noyau au point de Gauss tourne des dizaines de millions de fois dans une résolution non linéaire. Deux invariants l’encadrent :

  1. Aucun test que l’amont a déjà tranché — la présence d’une composante, la forme d’un champ, une Option à déballer. Les branchements qui restent sont ceux de la physique (l’essai élastique qui plastifie ou non).
  2. Aucune allocation dynamique — ni Vec, ni String, ni format!, ni structure intermédiaire construite par point.

La vérification n’est pas supprimée, elle est déplacée : un champ peut avoir été fabriqué à la main, on contrôle donc ses composantes et leur ordre une fois par zone, avant la région parallèle (Behavior::zone_layout pour la voie point, Domain::element_layout pour la voie matrice, kernel::element_pointwise), avec un message qui nomme le champ et l’écart. Le noyau reçoit alors la ligne de son point — une tranche empruntée du tampon — et une table d’indices : il indexe et calcule, rien d’autre. Un noyau conforme s’écrit sans un seul ? sur ses lectures, ce qui rend la règle vérifiable à l’œil.

Ce que cela vaut : sur la poutre console élasto-plastique 400×80, ~55 % du temps CPU partait dans la résolution de noms, le format!, le hachage et l’allocateur.

Zéro-copie

Les régions parallèles n’effectuent pas de copies intermédiaires. Plutôt que de recopier les données dans des Vec avant de calculer, on tient les read-guards pendant toute la région parallèle et on emprunte les tranches &[f64] en place (SubField::values(), SubMesh::connectivity(), dn_dx calculé à la volée). Les verrous de lecture sont concurrents : seuls les écrivains attendent. L’espace éléments finis n’a aucune mutabilité intérieure (méthodes &self pures), donc &SubFiniteElementSpace est Sync et appelable depuis plusieurs threads.

La règle ne s’arrête pas aux régions parallèles. Une .to_vec() sur une connectivité est suspecte partout, y compris dans du code séquentiel : elle coûte 4 octets par nœud et par copie, et elle se refait souvent à chaque assemblage ou à chaque évaluation de résidu. Deux idiomes reviennent, et aucun n’oblige à recopier :

  • Un opérateur de maillage lit toutes ses zones, puis écrit dans Coords (add_nodes, decref_all). Les deux phases sont séparées : la garde de lecture meurt avant l’écriture, il suffit de l’ouvrir pour la passe au lieu d’en extraire un tuple. Quand les deux se chevauchent vraiment, tenir la garde reste licite — SubMesh → Coords est l’ordre de verrouillage de tout le dépôt, jamais l’inverse, et SubMesh::to_poi1 le pratique déjà.
  • Un remplisseur de bloc ou de champ construit d’abord (un constructeur scelle, et sceller écrit), puis ouvre ses gardes et remplit. Les formes _with existent pour ça — SubMatrix::add_entry_with, SubNodeField::nodes_with : elles reçoivent le &SubMesh que l’appelant tient déjà, au lieu de reprendre un verrou à chaque entrée.

Les seules copies légitimes sont celles qu’on mute ensuite (dédoublonner, trier, étendre) et celles qu’impose une signature : une fonction qui rend une liste sans rendre la garde qui la porte, comme Cell::node_ids ou la frontière Python.

Déterminisme

Les opérateurs de champ et d’intégration parallélisés soit écrivent chaque case de sortie exactement une fois (écriture indexée / par_chunks_mut), soit sont une réduction associative sur les mêmes valeurs dans le même regroupement (min/max). Ces résultats sont bit-à-bit identiques au séquentiel, quel que soit RAYON_NUM_THREADS.

L’assemblage suit un schéma en deux temps. Le motif creux global (sparsité CSR) est construit depuis la seule topologie des blocs — indépendant des matériaux — puis mémoïsé sur le Model, donc réutilisé tel quel d’un assemblage à l’autre (le gain majeur pour une boucle de Newton, où seuls les matériaux changent). L’état assemblé partage ses tableaux d’indices avec ce motif : d’un assemblage au suivant, seules les valeurs sont neuves.

La phase numérique calcule et disperse dans la même passe, cellule par cellule, par coloration : deux cellules d’une même couleur ne partagent aucun DOF, donc les cellules d’une couleur écrivent en parallèle dans des cases disjointes — via un Vec<AtomicU64> (Relaxed, soit un simple mov sur x86, sans unsafe) — les couleurs étant traitées en séquence. Chaque tâche garde une matrice élémentaire de travail, réutilisée d’une cellule à l’autre : aucun ensemble élémentaire n’est matérialisé, là où les matérialiser toutes coûtait, sur un solide, des dizaines de gigaoctets écrits puis relus une fois. La coloration étant fixe, le résultat est déterministe (indépendant du nombre de threads), mais pas bit-à-bit face à une réduction séquentielle : la somme de chaque case est réordonnée par couleur. Un chemin de scatter séquentiel (en ordre de bloc), lui bit-à-bit, est conservé comme référence de test — c’est le seul qui calcule encore toutes les matrices élémentaires d’abord.

Le motif lui-même ne matérialise pas les paires (ligne, colonne) de chaque entrée : elles se déduisent de la position de chaque nœud dans les supports ligne et colonne, soit une poignée d’entiers par cellule au lieu d’une paire par entrée. Les cases CSR, elles, sont résolues une fois et gardées à plat ; quand l’ordre des DDL rend consécutives les colonnes d’un même nœud — ce que NodesThenVars vise, et que l’assembleur vérifie plutôt que de le supposer — une case de base suffit pour toutes les variables primales de ce nœud.

Le noyau élémentaire reçoit un CellGeom par espace EF du bloc : un seul pour une physique de continuum, plusieurs — partageant un maillage, ne différant que par la quadrature — pour un élément multi-quadrature (poutre de Timoshenko flexion/cisaillement, coque à venir). La sparsité ne dépendant que de la connectivité, ces éléments empruntent le même chemin de scatter parallèle sans machinerie supplémentaire : seul le noyau numérique lit plusieurs géométries.

Les scatters nodaux — les opérateurs Bᵀ (ops::node_field::divergence et les forces internes ops::node_field::internal_forces, Cast3m BSIG) et la charge répartie la charge répartie (∫ φ N) — dispersent tous de la même façon : chaque cellule calcule sa contribution locale, puis l’accumule dans ses nœuds. Ils passent tous par le même driver kernel::scatter_to_nodes (« intègre un noyau élémentaire et disperse aux nœuds »), qui s’appuie sur le helper parallel::colored_scatter : scatter par coloration (couleur = nœuds disjoints, Vec<AtomicU64>), le tampon local tenant par thread — aucun ensemble élémentaire n’est matérialisé, et calcul et scatter se font dans la même passe parallèle — comme l’assemblage matriciel, qui suit le même schéma. Le driver est agnostique à l’intégrande : l’appelant capture le sien (champ de contrainte, densité de flux…) dans la closure élémentaire. internal_forces est une divergence (de la contrainte), et ops::node_field::divergence en est le cas scalaire (n_dual = 1) ; flux en est l’instance « masse » pondérée par N plutôt que par ∇N. Déterministe par couleur, non bit-à-bit face à une somme en ordre de cellule.

Vérification : la suite de tests passe sous RAYON_NUM_THREADS=1 puis =8 ; les opérateurs write-once / réduction sont asservis à des valeurs exactes, l’assemblage parallèle à l’assemblage littéral à tolérance, plus un test de déterminisme.

Non bit-à-bit, par construction : l’assemblage parallèle et les scatters nodaux (divergence, forces internes, flux) — tous déterministes par coloration — et le solveur (back-end faer, pivotage/ordering différents de l’ancien LU dense). Tous dans les tolérances numériques.

Ce qui reste séquentiel (et pourquoi)

  • Fusion de champs — node_field.consolidate / element_field.consolidate (dédup et vérification de cohérence entre zones).
  • Mailleurs — les noyaux séquentiels par nature (Bowyer–Watson, front avançant) restent séquentiels.

Solveur

Le solveur utilise un LU creux multithreadé (faer) et met en cache la factorisation réutilisable dans la Matrix (factor once, solve many). Détails dans Opérateurs de solveur. Rôles des bibliothèques :

nalgebra            nalgebra-sparse                faer
primitives     →    stockage CSR/CSC + serde    →  factorise & résout (parallèle, creux)
(B, J, géométrie)   Matrice (scatter → CSR)        back-end solveur uniquement

Côté Python

Le parallélisme tourne à l’intérieur de chaque appel pyrucast (les threads rayon ne dépendent pas du GIL). Le GIL n’est pas relâché : deux appels pyrucast concurrents depuis des threads Python se sérialisent, mais un seul appel lourd profite pleinement de tous les cœurs.

Compilation et tests

Ce chapitre couvre l’installation complète pour développer sur pyrucast : tests Rust et Python, doctests, features Cargo, génération du stub .pyi, documentation. Pour le simple usage en Python (quatre commandes), voir Installation et démarrage rapide.

Prérequis

OutilVersionRôle
Rust (via rustup)≥ 1.89 (rust-version du crate), édition 2024Compilation du cœur
Python≥ 3.11 (3.13 testé)API Python et maturin
ruff0.16.4 (la version épinglée en CI)Format et règles du Python (ruff format, ruff check), vérifiés par check_format
mdbook0.5.4 (la version épinglée en CI)Génération de cette documentation
mdbook-mermaid0.17.1 (celle qui va avec mdBook 0.5)Rendu des graphes (ex. graphe des dépendances)

cargo fmt et clippy viennent avec la toolchain (rustup component add rustfmt clippy si besoin).

Système (uniquement pour builds avec l’API Python, voir features plus bas) :

  • Linux : installer les en-têtes Python — python3-dev (Debian/Ubuntu) ou python3-devel (Fedora/RHEL). pyo3 en a besoin pour l’édition de liens.
  • Windows : l’installateur officiel de Python inclut déjà les en-têtes.

Build Rust pur. Par défaut (cargo build / cargo test, sans feature), le crate ne compile ni pyo3 ni libpython : ni Python ni python3-dev ne sont requis. Voir « Usage en Rust pur » ci-dessous.

Mise en place du venv et des outils Python

Le venv (.venv à la racine) héberge maturin, pytest et ruff, et sert d’environnement cible à maturin develop.

Linux / macOS (bash)

python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip maturin pytest ruff==0.16.4

ruff est épinglé, comme en CI : ses règles par défaut et son formatage changent d’une version à l’autre, et check_format les vérifie tous deux.

Windows (PowerShell)

python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install --upgrade pip maturin pytest ruff==0.16.4

Important. Pour les builds avec l’API Python (maturin, ou cargo avec --features python-api/stub-gen), pyo3 cherche l’interpréteur via VIRTUAL_ENV : activez toujours le venv avant d’invoquer ces commandes. Un cargo build/cargo test pur (sans feature) n’a pas cette contrainte — il ne touche pas à Python.

Compilation et tests, étape par étape

Les commandes suivantes supposent le venv activé. Elles sont identiques sous Windows et Linux.

cargo fmt                  # format Rust — à passer AVANT toute vérification
ruff format .              # format Python — idem
cargo build                # cœur Rust pur (rlib + cdylib), sans pyo3
cargo test                 # tests unitaires + intégration + doctests (Rust pur)
cargo test --doc           # doctests explicitement
maturin develop            # construit et installe le module Python dans le venv
python -m pytest           # tests Python
cargo doc --no-deps --lib  # référence API Rust (rustdoc) — voir ci-dessous
mdbook build book          # génère cette documentation HTML

Le projet étant un mixed layout, maturin develop fait deux choses : il dépose l’extension compilée dans python/pyrucast/ (à côté des .py), et installe le paquet en mode editable dans le venv — un simple site-packages/pyrucast.pth pointant vers python/, pas une copie. Toute modification du Rust est donc intégrée au prochain maturin develop, et une modification des seuls .py est visible immédiatement.

Documentation Rust (rustdoc)

cargo doc --no-deps --lib génère la référence API Rust à partir des commentaires ///. Le résultat est dans target/doc/pyrucast/index.html.

  • --no-deps exclut la doc des dépendances (pyo3, serde, bincode, nalgebra) — gain de temps majeur.
  • --lib limite à la crate pyrucast (sans les tests d’intégration).
  • --open ouvre la page dans le navigateur à la fin.

Rustdoc et mdbook sont complémentaires : rustdoc couvre la référence par item ; ce mdbook couvre les principes, l’architecture et les exemples transverses.

La référence rustdoc est publiée à côté du book sur GitHub Pages, à https://pyrucast.github.io/pyrucast/rust/ (lien également présent en tête de l’introduction). Sa génération et son déploiement sont décrits dans « Publication automatique du book » ci-dessous.

Features Cargo

Plusieurs morceaux sont gardés derrière des features Cargo : ce qu’on n’active pas n’est ni compilé, ni embarqué. Toutes optionnelles, elles s’activent en cumul (--features a,b).

Sans aucune feature, le crate est du Rust pur : pyo3 n’est même pas compilé (dépendance optionnelle), donc aucun lien à libpython.

FeatureApportImpliqueQuand l’activer
python-apitire la dépendance pyo3 + compile le code des #[pyclass]/#[pyfunction] (toute l’API Python)dep:pyo3rarement à la main ; activée automatiquement par extension-module et stub-gen. La désactiver (défaut) donne un crate Rust pur.
extension-modulepython-api + dit à pyo3 de ne pas se lier à libpython (l’interpréteur hôte la fournit au chargement du .so)python-api, pyo3/extension-modulesystématique pour maturin develop / maturin build.
vizexport PNG/SVG (rendu CPU via plotters)—scripts headless, captures pour la doc, CI.
viz-interactivefenêtre interactive winit/softbuffer (souris, gizmo)vizenvironnement graphique disponible.
stub-genpython-api + binaire stub_gen qui produit le stub .pyipython-api, pyo3-stub-genaprès modification des bindings, pour rafraîchir le stub vu par les IDE.

extension-module vs stub-gen. extension-module produit un .so chargé par Python : pyo3 se passe alors du link à libpython. À l’inverse, stub-gen produit un binaire exécutable : il faut libpython linkée normalement, donc on n’active pas extension-module ce jour-là. Les deux ne sont jamais activées ensemble.

Usage en Rust pur

pyrucast s’utilise comme bibliothèque Rust sans rien de Python. Comme pyo3 est optionnel, une dépendance par défaut ne compile ni le binding ni libpython :

# Cargo.toml d'un projet Rust tiers
[dependencies]
pyrucast = { path = "…", default-features = false }   # Rust pur, pas de pyo3
#[test]
fn mailler_une_surface_depuis_un_contour() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let coins: Vec<Node> = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]
        .iter()
        .map(|p| Node::create_in(coords.clone(), p).unwrap())
        .collect();
    let mut sm = SubMesh::new(coords, ElementType::SEG2);
    for i in 0..4 {
        sm.add_cell(&[coins[i].id(), coins[(i + 1) % 4].id()])?;
    }
    let contour = Mesh::from_submesh(sm);

    let mesh = mesh::triangulate_surface(&contour, ElementType::TRI3, Some(1.0))?;

    assert!(mesh.cell_count() > 0);
    Ok(())
}

Tout le cœur (containers, ops, interrupt, handle, …) est disponible et PyO3-free. Seule la couche py (les #[pyclass]) demande python-api. Pour interrompre un calcul long depuis du Rust pur, voir Interrompre une fonction.

Génération du stub Python (.pyi)

Les IDE (Pylance/Pyright, PyCharm) ne savent pas inspecter un .so compilé : sans stub, les complétions tombent sur Any. Le binaire stub_gen lit les annotations /// + les macros #[gen_stub_*] et écrit un stub complet :

# Activer le venv comme d'habitude (le binaire link à libpython).
source .venv/bin/activate
cargo run --bin stub_gen --features stub-gen

Le fichier produit est python/pyrucast/_pyrucast/__init__.pyi — le stub de l’extension compilée, à côté du paquet Python (mixed layout). Son chemin n’est pas choisi par le binaire : pyo3-stub-gen le dérive de python-source + module-name dans pyproject.toml.

Régénérer le stub à chaque changement de signature Python (nouvelle classe, paramètre, docstring ///). Le fichier est versionné dans le repo, utilisable tel quel par les IDE.

Les dunders polymorphes des agrégats

Une exception : __getitem__, __or__ et __ror__ des agrégats (Mesh, Model, NodeField, …) prennent et rendent un PyAny, dont le générateur ne peut déduire que typing.Any — et mesh[0]. ne proposerait alors plus rien dans l’IDE. Ces méthodes vivent donc dans des blocs #[pymethods] volontairement non décorés par gen_stub_pymethods, invisibles au générateur, et leurs entrées du .pyi sont écrites à la main en syntaxe Python dans les deux littéraux passés à impl_aggregate_pymethods! (voir src/py/mesh.rs) : surcharges @overload typées et docstrings propres à chaque agrégat, ce qui permet aussi de dire au bon endroit ce que | fait vraiment sur ce type-là. Le nom de classe y désigne le type Rust (class PyMesh:), et les types renvoyés passent par le marqueur pyo3_stub_gen.RustType[...].

Ces blocs non décorés sont fermés : une méthode qu’on y ajouterait disparaîtrait silencieusement du stub. Toute nouvelle méthode d’agrégat va dans les blocs décorés ; une méthode polymorphe de plus demande d’étendre le littéral correspondant, sur les sept sites d’appel de la macro.

Le même patron — bloc fermé + submit! adjacent — sert partout où une méthode écrite à la main est polymorphe : SubNodeField.__getitem__, SubElementField.__getitem__, Node.__or__/__ror__, Matrix.__mul__, et les sommes __add__/__sub__ de Matrix et SubMatrix (plus le __neg__ du bloc, logé dans le même bloc fermé). Il sert aussi à corriger une différence de vocabulaire : pyo3 regroupe les comparaisons sous __richcmp__, qui n’existe pas côté Python — le stub des champs déclare donc à la main __ge__/__gt__/__le__/__lt__, qui rendent un masque 0/1 et non un booléen.

Scripts « tout-en-un »

Le dossier script/ contient deux niveaux d’automatisation. Tous activent (ou créent) le venv automatiquement et s’arrêtent à la première erreur.

Autour d’eux gravitent quelques utilitaires : dev.sh / dev.ps1 (build minimal — module Python en release avec la visu interactive + stub, rien d’autre), run_examples.sh / run_examples.ps1 (exemples et tutoriel de bout en bout, appelés par check_examples), set_new_version.sh (passe de version : check_all puis check_clippy, reporter le numéro dans Cargo.toml — seule déclaration de version du projet —, commit + tag : il ne pousse rien), publish_macros.sh (vérifie et publie pyrucast-macros sur crates.io, le seul paquet que la CI ne publie pas), scaling.sh (mesure de montée en charge du parallélisme) et generate-tutoriel-figures.sh (régénère les SVG du tutoriel, qui sont des artefacts commités).

script/check_*.sh — vérifications, en bloc ou à la carte

La vérification est découpée en cinq blocs indépendants, chacun lançable seul, plus deux enchaîneurs. Chaque bloc existe en bash (.sh, Linux/macOS) et en PowerShell (.ps1, Windows), au comportement identique.

Le tableau ci-dessous donne, commande par commande, ce qu’elle vérifie, où vivent les tests qu’elle exécute, ce qu’elle coûte, et quels scripts la lancent. Les durées sont mesurées à chaud — arbre déjà compilé, ce qui est le cas courant ; un premier tour après un changement de features paie en plus la recompilation.

commandece qu’elle testeoù vivent les teststempsquickfmtrustpyexdocallversion
cargo fmt --checkle Rust est formaté—1 s✓✓✓✓
ruff format --check .le Python est formaté—1 s✓✓✓✓
ruff check .le Python passe les règles par défaut de ruff 0.16.4 (exceptions dans pyproject.toml, une par motif)—<1 s✓✓✓✓
cargo check --all-targetsle build Rust pur compile encore—<1 s✓✓✓✓
cargo test --features viz1036 tests + 893 doctests#[cfg(test)] dans 123 fichiers de src/, 42 fichiers tests/*.rs, commentaires /// et //!154 s✓✓✓✓
cargo build --features viz-interactivela fenêtre winit compileaucun test — 960 lignes que rien n’exerce sans écran14 s✓✓✓✓
maturin develop --release --features extension-module,vizl’extension Python s’installe, compilée en release comme la roue—4 s✓✓✓
python -m pytestl’API Python, unité par unité73 fichiers tests/python/test_*.py, dont 13 test_doc_*.py qui sont aussi les sources des exemples du book32 s✓✓✓
script/run_examples.shdes chaînes de calcul de bout en bout30 examples/*.py, 3 examples/*.rs, 6 tutoriel/*.py80 s✓✓✓
cargo doc --no-deps --lib (-D warnings)la rustdoc n’a aucun lien cassé—1 s✓✓✓
mdbook build bookle book se rendbook/src/**1 s✓✓✓
python script/doc_lint.pyles six garde-fous du book et des exemplesbook/src/**.md, le book construit, la rustdoc, le module Python installé28 s✓✓✓
cargo clippy × 4 jeux de featuresaucun avertissement, -D warnings—105 s✓

Et les totaux, dans le même régime :

scriptcontenutemps
check_formatformatage seul2 s
check_rustcœur Rust169 s
check_pythonliaison et tests Python50 s
check_examplesexemples et tutoriel46 s
check_docrustdoc, garde-fous, book50 s
check_clippyquatre jeux de features, -D warnings~2 min
check_gmshl’interface gmsh (exige pip install gmsh)5 s
check_medcouplingl’interface MED (exige pip install medcoupling)2 s
check_quickformatage + Rust — la boucle de commit~3 min
check_allles cinq blocs~6 min
set_new_versioncheck_all + check_clippy~8 min

check_gmsh ne fait pas partie de check_all, pour la même raison que check_clippy n’en fait pas partie : on ne l’impose pas à la boucle quotidienne. Un développeur qui ne touche pas à l’import gmsh n’a donc pas à installer gmsh — les tests concernés se sautent proprement. On le lance en touchant à cette interface, et le job verify de la CI le lance toujours. Sous Linux, la roue gmsh embarque un libgmsh lié à OpenGL : il faut aussi les paquets système libglu1-mesa et libopengl0.

check_medcoupling suit la même règle pour l’interface MED. medcoupling n’est publié que pour Linux x86_64 et Windows : sur macOS, ce bloc ne peut pas tourner.

Deux remarques que ces chiffres appellent.

Les doctests ne coûtent presque plus rien, alors qu’ils représentaient 44 % de la suite entière. Le crate est passé à l’edition 2024, qui les fusionne en un seul binaire au lieu d’en compiler un par exemple : 893 doctests sont passés de 232 s à 17 s. Ce qui domine aujourd’hui le bloc Rust, ce n’est plus l’exécution des tests (37 s) mais la compilation des 42 fichiers de tests d’intégration.

Les doctests tournent sous viz, jamais sous viz-interactive. La couche interactive tire 58 crates de plus — winit, Wayland, X11 — et chaque binaire de test les lie. Elle est donc compilée sans être testée, ce qui suffit : elle ne porte aucun test qu’un écran ne soit nécessaire pour exercer.

bash script/check_quick.sh   # la boucle de commit : formatage + Rust
bash script/check_doc.sh     # je viens de toucher à la doc
bash script/check_all.sh     # la passe complète, à brancher en CI
.\script\check_doc.ps1        # Windows
.\script\check_all.ps1

check_all enchaîne les cinq dans cet ordre, qui n’est pas indifférent : le formatage d’abord (il échoue en une seconde), le cœur Rust ensuite, puis Python — qui (ré)installe l’extension dont les exemples ont besoin —, les exemples, et la documentation en dernier, la plus lente. Un bloc lancé seul qui a besoin du module compilé le dit plutôt que d’échouer obscurément.

script/check.sh reste comme alias de check_all.sh. check_quick est le raccourci du cas courant : il ne remplace pas check_all, il le précède — les exemples, Python et la doc restent à passer avant de pousser.

set_new_version.sh appelle check_all plutôt que de recopier ses pas : la copie précédente avait divergé, et ne lançait ni les garde-fous de documentation ni les exemples au moment précis où l’on pose un tag.

Trois points valent d’être notés :

  • les deux vérifications de format viennent en tête — le script vérifie, il ne formate pas. Passer cargo fmt et ruff format . avant de le lancer, sinon il s’arrête au premier pas ;
  • run_examples rejoue les exemples et les scripts du tutoriel de bout en bout. Ce sont des chaînes de calcul complètes : elles attrapent ce que les tests unitaires laissent passer, typiquement une méthode renommée dont plus personne ne se sert ;
  • ne jamais piper check_all (| tail, | grep) : le code de retour devient celui du dernier maillon du tube, et un échec passe pour un succès.

Les extraits du book sont du code exécuté

Aucune page du book ne possède de code : tout bloc rust ou python est un {{#include}} pointant un test, un exemple ou une source. Le code affiché est donc exécuté par check_rust, check_python ou check_examples, et il ne peut plus diverger de l’API.

check_doc ne compile donc rien lui-même. Il construit la rustdoc et le book, puis lance script/doc_lint.py, six vérifications de texte :

garde-fouce qu’il tient
includeschaque {{#include}} résout : fichier, ancre, texte non vide
fencesaucune page ne possède de code
symbolesla prose ne cite aucun symbole disparu
doctestscliquet : la couverture des doctests ne peut que monter
api-pythoncliquet : toute entrée Python est citée par un exemple exécuté
equationschaque équation arrive intacte à MathJax

Le premier est le plus important, parce que le mécanisme porteur n’est gardé par rien d’autre : une ancre inexistante rend un bloc vide, avec un code de retour 0 et sans un mot.

mdbook test a été retiré de la chaîne : tous les blocs Rust du book sont rust,ignore — c’est ce que le mécanisme d’inclusion impose —, si bien qu’il ne compilait rien. Un pas vert qui ne regarde rien coûte plus cher que son absence.

La règle complète, et le choix entre doctest, test d’intégration et exemple, est page Documentation et tests.

script/build.sh / script/build.ps1 — build complet + documentation

Le script « tout faire de bout en bout », Linux/macOS (build.sh, bash) et Windows (build.ps1, PowerShell) :

bash script/build.sh                         # Linux / macOS
.\script\build.ps1                           # Windows
# si les scripts sont bloqués :
powershell -ExecutionPolicy Bypass -File .\script\build.ps1

Il déroule, dans l’ordre :

  1. Vérification des prérequis — cargo, python (≥ 3.11), création/activation du venv, installation de maturin et pytest, installation de mdbook si absent (cargo install mdbook).
  2. Compilation + tests — cargo build, cargo test (unitaires + intégration + doctests), cargo test --doc, cargo test --features viz.
  3. Module Python avec visu interactive — maturin develop --release --features extension-module,viz-interactive, puis pytest.
  4. Documentation — cargo doc (référence Rust), régénération du stub python/pyrucast/_pyrucast/__init__.pyi, mdbook build, et un export pydoc HTML de l’API Python (target/python-doc/pyrucast.html).
  5. Vérification finale — le module s’importe et la visualisation est bien compilée (Mesh.plot présent) ; sinon le script échoue.
  6. Résumé — emplacements des trois documentations (book, rustdoc, pydoc) et les commandes pour ouvrir le livre et lancer une fenêtre interactive.

À la fin, la librairie Python est installée dans le venv avec la visualisation interactive : il suffit d’activer le venv et d’appeler mesh.plot() (save=None ouvre la fenêtre — cf. Visualisation).

Publication automatique du book

Le book est publié sur GitHub Pages à l’adresse https://pyrucast.github.io/pyrucast/, et la référence rustdoc à côté, sous https://pyrucast.github.io/pyrucast/rust/.

La publication est automatisée par un workflow GitHub Actions (.github/workflows/pages.yml) : à chaque push sur master qui touche au book ou aux sources Rust, le workflow build le book (mdbook build book) et la doc Rust (cargo doc --no-deps --lib), assemble un site combiné — le book à la racine, la rustdoc sous rust/ — et le déploie directement sur GitHub Pages via actions/deploy-pages (pas de branche pages, pas de token à gérer : le déploiement utilise les permissions natives du workflow). Il peut aussi être déclenché à la main (workflow_dispatch) depuis l’onglet Actions.

Prérequis à configurer une fois côté GitHub (interface web) :

  1. Settings → Pages → Build and deployment → Source : sélectionner GitHub Actions.

Les runners GitHub sont hébergés et gratuits sur dépôt public — pas de runner à enregistrer soi-même, contrairement à Codeberg.

Journal des versions et publication

Poser un tag vX.Y.Z déclenche .github/workflows/release.yml, qui publie le crate sur crates.io, les wheels et la sdist sur PyPI, puis crée la Release GitHub dont les notes sont écrites automatiquement à partir des messages de commit.

La convention de message de commit

C’est la seule obligation que cette automatisation ajoute, et le projet la suit déjà partout. Un sujet s’écrit type(portée): sujet, avec un ! avant le deux-points quand le changement casse l’API :

PréfixeSection du journal
type(portée)!: — n’importe quel type suivi de !Ruptures d’API, en tête
featNouveautés
fixCorrections
perfPerformances
refactorRemaniements
docsDocumentation
testTests
build, ci, choreCompilation et outillage
styleStyle
tout le resteDivers

Le sujet est repris tel quel dans les notes : il est écrit une fois, au moment du commit, et jamais retouché ensuite. Le corps du message, lui, n’est jamais publié — il reste pour qui lit git log. Deux cas particuliers sont traités par la configuration : chore: version X.Y.Z est sauté, et les commits docs(rustdoc) sont repliés en une seule ligne comptée, sans quoi ils noieraient tout le reste.

git-cliff n’est pas un prérequis

Le tri est fait par git-cliff, piloté par cliff.toml à la racine. Rien à installer : ni pour développer, ni pour publier. L’outil est téléchargé par le workflow, dans une version épinglée, comme mdBook l’est pour le book. Le tableau des prérequis en tête de page ne gagne donc aucune ligne.

Pour relire les notes avant de poser le tag, si on le souhaite :

cargo install git-cliff
git-cliff --unreleased --tag vX.Y.Z   # ce que la prochaine Release dira

Et si les notes publiées déplaisent, la Release s’édite dans l’interface GitHub : ni workflow à rejouer, ni tag à déplacer.

Une seule déclaration de version

Cargo.toml porte la version, et lui seul. pyproject.toml la lit de là (dynamic = ["version"]), et pyrucast.__version__ en vient déjà (src/lib.rs, env!("CARGO_PKG_VERSION")). Le crate, la wheel et le module importé descendent donc du même champ : ils ne peuvent pas diverger.

Le workflow refuse de démarrer si pyproject.toml redéclarait une version — une version statique l’emporterait sur Cargo.toml chez maturin et partirait seule sur PyPI.

De là l’invariant qui rend la page releases/ lisible :

La Release GitHub existe si et seulement si crates.io et PyPI portent réellement cette version, et si les artefacts joints portent les étiquettes de compatibilité attendues.

Le dernier job constate au lieu de supposer : il interroge les deux registres jusqu’à les y trouver, et vérifie sur les noms de fichiers qu’il y a bien trois wheels cp311-abi3 et une sdist. cargo publish comme l’action PyPI savent sauter une version déjà présente ; ce silence est utile pour rejouer un job, et dangereux partout ailleurs.

Le tag est le seul point de contrôle

Les push sur master ne sont pas vérifiés — assumé pour l’instant. La vérification se fait là où elle est irréversible : au tag. Le job verify précède tout le reste et lance exactement ce que set_new_version.sh lance en local, les cinq blocs de check_all puis check_clippy.

Un bloc par étape nommée, et non check_all.sh en une fois : l’interface Actions montre alors laquelle rougit sans qu’il faille dérouler tout le journal. Ce sont les mêmes scripts, appelés un à un plutôt que par leur boucle — et une étape vérifie que la liste des cinq blocs de la CI est encore celle de check_all.sh, faute de quoi un sixième bloc ne serait jamais lancé au moment de publier.

Un seul job, parce que l’ordre contraint : doc_lint.py importe pyrucast, donc check_doc exige que check_python ait construit le module.

Le job reconstitue l’environnement de développement — toolchain Rust, en-têtes fontconfig et freetype, mdBook et son préprocesseur mermaid, un venv avec maturin, pytest et ruff. Compter 20 à 30 minutes à froid, 10 à 15 ensuite grâce au cache Cargo.

set_new_version.sh garde malgré tout ses huit minutes : c’est l’échec rapide, avant que le tag existe. Découvrir la panne après coup obligerait à déplacer un tag, ou à brûler un numéro.

pyrucast-macros se publie à la main, et avant

Le workspace porte deux paquets, et release.yml n’en publie qu’un : le paquet racine. pyrucast dépend pourtant de pyrucast-macros en path et en version, donc cargo package comme cargo publish cherchent ce numéro dans l’index de crates.io. Tant qu’il n’y est pas, les deux échouent sur no matching package named pyrucast-macros found — y compris set_new_version.sh, à son étape cargo package, avant même de poser le tag.

L’outillage de macros bouge bien plus lentement que la bibliothèque, et sa version est délibérément décorrélée de la sienne : le publier à chaque tag n’aurait aucun sens. Il se publie donc à la main, à chaque fois qu’il change :

bash script/publish_macros.sh          # publie la version courante
bash script/publish_macros.sh 0.1.1    # la passe à 0.1.1, puis publie

Le script vérifie la crate seule (format, clippy -D warnings, tests, rustdoc) puis son consommateur — une macro procédurale ne prouve rien tant qu’un appelant ne l’a pas expansée —, empaquette, fait un --dry-run, et ne publie qu’après confirmation. Avec un numéro, il le reporte des deux côtés : dans macros/Cargo.toml et dans la dépendance de Cargo.toml, qui ne peuvent pas diverger sans que la publication cesse de débloquer quoi que ce soit. Il attend enfin que l’index serve la version, faute de quoi le cargo package suivant échouerait exactement comme avant.

On construit tout avant de publier

L’ordre des jobs compte autant que l’attestation finale. On construit tout avant de publier quoi que ce soit : crates-io attend la sdist et les wheels, parce que cargo publish est une porte à sens unique et qu’un numéro ne se republie jamais. La 0.3.2 a servi de démonstration — elle est partie sur crates.io pendant qu’une cible de la matrice échouait, et PyPI ne l’a jamais reçue.

Ce que porte chaque distribution

pip install pyrucast prend une wheel quand il en existe une pour la plateforme, et retombe sinon sur la sdist, qu’il compile. Les deux ne portent pas la même chose :

PlateformesFeatures compiléesPrérequis
wheel cp311-abi3Linux x86_64 (manylinux2014), Windows x86_64, macOS universal2extension-module, viz, viz-interactiveaucun — Python ≥ 3.11
sdist .tar.gztout le reste (ARM, musl, BSD…)extension-module seulrustup, et la compilation du crate

Il n’y a pas de wheel Linux aarch64 : viz lie fontconfig et freetype, et pkg-config refuse par principe de fonctionner en cross-compilation. La construire supposerait d’émuler un conteneur aarch64 — trois minutes de compilation x86_64 en deviennent quinze à trente. À reprendre séparément, sans version en jeu.

Une installation depuis la sdist n’a donc pas la visualisation : mesh.plot() n’existe pas. C’est assumé — compiler viz exigerait fontconfig et freetype sur la machine cible — mais ce n’est pas silencieux :

features = pyrucast.__features__
assert isinstance(features, tuple)
assert features, "an imported module compiles at least extension-module"
assert all(isinstance(f, str) for f in features)
assert "extension-module" in features

__features__ liste ce que ce binaire porte : ('python-api', 'extension-module', 'viz', 'viz-interactive', 'abi3') sur une wheel publiée, ('python-api', 'extension-module') depuis la sdist. Le test qui précède ne se contente pas de lire la liste — un second vérifie qu’elle dit vrai plutôt que d’être recopiée à la main : la présence de viz doit équivaloir à celle de Mesh.plot.

Pré-versions. Un tag v0.4.0-rc1 reste possible, mais crates.io garderait 0.4.0-rc1 là où PyPI normalise en 0.4.0rc1 : le workflow rapproche les deux formes et refuse celles qu’il ne sait pas superposer. Il faudrait aussi élargir la regex de test_version_exposed, qui n’accepte aujourd’hui que X.Y.Z.

Dépannage rapide

  • error: failed to run the Python interpreter at ... lors d’un cargo build : le venv n’est pas activé, ou un VIRTUAL_ENV obsolète pointe vers un chemin invalide. Réactivez le venv du projet.
  • No module named 'pyrucast' lors de pytest : maturin develop n’a pas déposé le module. Vérifier que VIRTUAL_ENV est défini, puis relancer maturin develop.
  • mdbook: command not found : installer mdbook via cargo install --locked mdbook --version 0.5.4 ou un binaire publié.
  • The "mermaid" preprocessor exited unsuccessfully (ou graphes affichés en bloc de code brut) : installer le préprocesseur via cargo install --locked mdbook-mermaid --version 0.17.1. Il doit être sur le PATH au moment de mdbook build, et aller avec mdBook : la 0.17 parle le protocole de mdBook 0.5, la 0.16 celui de la 0.4.

Ajouter une physique

Ce chapitre liste tous les points de code à toucher pour ajouter une nouvelle physique. L’architecture est conçue pour que ce coût soit O(1) fichier, indépendant du nombre de physiques déjà présentes — voir Pourquoi ça passe l’échelle en fin de chapitre.

Le principe en une phrase

L’énum SubModel ne sert qu’au stockage et à la sérialisation ; tout le comportement vit dans une struct par physique (sous src/models/) qui implémente le trait SubModelKind. Un unique point de dispatch, SubModel::as_kind(), relie les deux. Le code générique (l’agrégat Model, l’assembleur, Dump) ne fait jamais de match par variante.

SubModel  (enum : stockage + sérialisation bincode)
├── HeatConduction(HeatConduction)
├── Dirichlet(Dirichlet)
├── … une variante par physique
└── as_kind(&self) -> &dyn SubModelKind   ← l'unique match

SubModelKind  (trait de base : le dénominateur commun de tout sous-modèle)
├── primal_vars / dual_vars                      ── les variables
├── physics       -> &'static [Physics]          ── la nature (requise)
├── as_domain     -> Option<&dyn Domain>        (défaut : None)  ── seam capacité
├── as_behavior   -> Option<&dyn Behavior>      (défaut : None)  ── seam capacité
├── as_constraint -> Option<&dyn Constraint>    (défaut : None)  ── seam capacité
│   └── matrix_element(kind, …)                  (le pont vers Domain, fourni)
├── stiffness_layout / mass_layout               (blocs calculés ; défaut : None)
│   geometric_layout / tangent_layout
│   └── matrix_layout(kind)                      (le dispatcher, fourni)
├── contributions(kind, material)                (défaut : dérivé du layout)
├── build_stiffness_blocks                       (défaut : dérivé du layout)
├── internal_force_element                       (défaut : continuum Bᵀσ)
├── internal_force_contribution                  (défaut : Computed(layout))
├── external_force_contribution                  (défaut : rien)
└── label / display / render

Sous-traits « capacité », miroir des natures de sous-modèle (une struct
n'implémente que celui qui la concerne) :
├── Domain      { material_fespace, material_components,
│                 optional_material_components,
│                 element_matrix & consorts (tous fournis) }
├── Behavior: Domain
│              { behavior_fespace, behavior_output_components,
│                deformation_reads, strain_operator, integrate_point,
│                zone_layout, integrate_behavior, element_tangent (fournis) }
└── Constraint  { multiplier_mesh, relations }

Les étapes

Ajouter une physique de forme courante — celle qui couvre un espace éléments finis — coûte un fichier et une ligne :

  1. src/models/<ma_physique>.rs (nouveau) — une struct portant ses supports, un impl SubModelKind, un constructeur new(...), ses tests, et une invocation de physics_operator! qui déclare l’opérateur public avec sa documentation (calque sur truss.rs, le cas le plus court).
  2. src/containers/model.rs — une variante dans enum SubModel et une ligne dans SubModel::as_kind().

Plus deux lignes de raccordement : pub mod dans src/models/mod.rs, pub use dans src/ops/model/mod.rs, et l’enregistrement dans le #[pymodule] de src/lib.rs (le module _pyrucast est plat).

Ce qu’on n’écrit plus. Le balayage des sous-espaces, le #[pyfunction], son attribut de stub, le déballage des enveloppes Python : physics_operator! les émet. Un auteur de physique n’ouvre jamais src/py/.

Un argument peut porter une valeur par défaut, arg: T = défaut, comme la mesure de la barre ci-dessous. Rust n’en a pas : son appelant passe l’argument ; la face Python le prend en mot-clé, avec cette valeur. Même nom, l’idiome de chaque langage. La macro écrit cette valeur dans la documentation Rust, pour qu’un lecteur qui n’écrit que du Rust sache ce que Python obtient sans rien dire.

#![allow(unused)]
fn main() {
crate::physics_operator! {
    /// Truss / bar `Model` spanning **every** subspace of `fespace` — one
    /// [`SubModel::Truss`] per
    /// [`SubFiniteElementSpace`].
    /// Parent-level operator; material (`E`, `A`) is supplied at assembly time.
    ///
    /// ```
    /// # 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, SubModel};
    /// # use pyrucast::coords::Coords;
    /// # use pyrucast::handle::Handle;
    /// # use pyrucast::models::tensor::Kinematics;
    /// # use pyrucast::models::symmetry::MaterialSymmetry;
    /// # use pyrucast::models::{Physics, RelationSense};
    /// # use pyrucast::ops::mesh;
    /// # use pyrucast::ops::model;
    /// # let coords = Handle::new(Coords::new(2).unwrap());
    /// # let n: Vec<Node> = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]
    /// #     .iter().map(|p| Node::create_in(coords.clone(), p).unwrap()).collect();
    /// # let mut sm = SubMesh::new(coords.clone(), ElementType::TRI3);
    /// # sm.add_cell(&[n[0].id(), n[1].id(), n[2].id()]).unwrap();
    /// # let maillage = Mesh::from_submesh(sm);
    /// # let fes = FiniteElementSpace::lagrange1(&maillage).unwrap();
    /// # let zone = fes.get(0).unwrap();
    /// # let impose = mesh::poi1_from_nodes(&n[..1]).unwrap();
    /// # let mut b = SubMesh::new(coords.clone(), ElementType::SEG2);
    /// # b.add_cell(&[n[0].id(), n[1].id()])?;
    /// # let barres = FiniteElementSpace::lagrange1(&Mesh::from_submesh(b))?;
    /// # use pyrucast::ops::element_field::StrainMeasure;
    /// let m = model::truss(&barres, StrainMeasure::Linear)?;
    /// assert_eq!(m.primal_vars(), vec!["u_x".to_string(), "u_y".to_string()]);
    /// # Ok::<(), pyrucast::PyrucastError>(())
    /// ```
    pub fn truss(fespace, strain_measure: StrainMeasure = StrainMeasure::Linear) via SubModel::truss;
    python: "`model.truss(fespace, strain_measure=\"linear\")` — truss / bar\n(axial-force) model spanning **every** subspace of `fespace` (SEG2 elements).\nDOFs are the vector displacement `u_x, u_y(, u_z)`; the orientation is taken\nfrom the node coordinates. Material (`E`, `A`) is supplied at assembly time.\n\n`strain_measure` is the strain the law reads: `\"linear\"` (the default; it\nwrites the axial force `n`) or `\"green_lagrange\"` (large displacements: it\nwrites the second Piola–Kirchhoff axial force `S_n`, which\n`element_field.stress_measure` turns into the nominal force vector `P_*` or\nthe true force `n`)."
}
}

Un terme qui écrit dans les lignes d’une autre physique — une charge répartie (flux), un échange de bord (boundary_transfer), un rayonnement (radiation) — déclare la forme (fespace, target, …). Il a besoin du modèle qu’il charge ou refroidit : pour y vérifier que ses lignes sont assemblées, y lire la duale de chaque variable et, quand ses noms de variables sont libres, en hériter la nature. La macro fait suivre la cible au constructeur de chaque zone et l’ajoute à la face Python ; l’auteur appelle transfer::target_pairs (des variables, dont la cible donne les duales), transfer::target_physics (un couple primale/duale fixé, comme le rayonnement) ou transfer::owner_physics (une ligne seule) dans son new, une fois, à la construction.

Deux blocs de documentation, et c’est voulu : le Rust porte son /// et son doctest, le Python un littéral qui atterrit dans le .pyi. Les partager mettrait un doctest Rust dans une docstring Python.

Les formes que la macro ne couvre pas

Elle sert le balayage — le cas d’une physique nouvelle. Restent écrits à la main, chacun avec sa raison :

  • les contraintes (dirichlet, mpc, embedded, contact), portées par des maillages fournis par l’utilisateur et non par un espace EF ;
  • interface_transfer, qui apparie deux espaces avec un contrôle de longueur.

Ajouter une loi plutôt qu’une physique

Une loi de comportement n’est pas une physique : c’est un attribut d’une physique existante, qui en partage les DDL, la modélisation et les layouts. Elle coûte son fichier sous src/models/<physique>/ — struct unitaire, impl du trait de sa famille, façade physics_operator! déléguant à l’opérateur générique — plus une variante d’énuméré et un bras dans as_law().

Deux niveaux, et ils ne disent pas la même chose : l’énuméré porte l’identité physique — c’est lui que bincode archive, et une nouvelle variante va donc toujours en fin de liste — tandis que le trait porte la structure d’intégration, celle qui fixe sa signature. D’où trois familles, nommées d’après cette structure et non d’après la physique :

traitce que le sous-modèle fait avant d’appeler la loisignatureénuméré
StatelessLawKindrien : la loi ne voit ni état, ni dtstress(ε, matériau)ElasticLaw
ReturnMapLawKindle prédicteur élastiquereturn_map(σ_essai, prev, matériau, dt)PlasticLaw
DirectUpdateLawKindrien : la loi reçoit ε et l’état de Aupdate(ε, prev, matériau)DamageLaw

C’est bien la structure qui décide, pas le nom : ViscoplasticLemaitreChaboche est physiquement « viscoplasticité + endommagement » et siège du côté plastique, parce qu’elle est de forme retour radial. Une loi hyperélastique rejoindrait StatelessLawKind ; une loi viscoélastique, DirectUpdateLawKind.

Tout le reste est générique et ne change pas.

Le trait SubModelKind

Défini dans src/models/mod.rs. Le trait de base ne porte que le dénominateur commun de tout sous-modèle ; chaque capacité optionnelle est un sous-trait séparé, exposé par un seam as_*() qui rend None par défaut. Ces sous-traits nomment chacun un fait, et un seul : Domain — j’intègre sur un espace EF, avec du matériau —, Behavior — et j’ai une loi évaluée en chaque point —, Constraint — mes relations existent sous forme neutre, on peut m’imposer autrement que par mes blocs. Behavior a Domain pour supertrait : toute loi s’intègre, toute intégration n’a pas de loi. C’est précisément ce qui manquait — un transfert de bord intègre ∫ h NᵀN et n’a aucune loi ; tant que les deux faits étaient un seul, il devait s’en inventer une. Une struct n’implémente que la capacité qui la concerne : elle n’a donc jamais de méthode « présente mais qui erronerait ». Un domaine typique implémente primal_vars, dual_vars, physics, as_domain + Domain, le noyau element_matrix, stiffness_layout, label et render. Il n’écrit pas build_stiffness_blocks : le défaut le dérive de stiffness_layout + element_matrix.

Seules trois méthodes sont sans défaut : primal_vars, dual_vars et physics (plus label / render pour l’affichage). Tout le reste se redéfinit à la carte.

pub trait SubModelKind: Sync {
    fn primal_vars(&self) -> Vec<String>;
    fn dual_vars(&self) -> Vec<String>;
    // Nature(s) de la physique — slice constante, pendant de `label` ; sert
    // aux sélecteurs `Model::filter` / `Matrix::filter` (match par appartenance) :
    fn physics(&self) -> &'static [Physics];
    // Seams de capacité — None (défaut) ⇒ la struct n'a pas cette capacité.
    // Une struct qui l'a redéfinit le seam pour rendre `Some(self)` :
    fn as_domain(&self)     -> Option<&dyn Domain>     { None }
    fn as_constraint(&self) -> Option<&dyn Constraint> { None }

    // Le pont vers les noyaux de matrice, qui vivent sur `Domain` (voir plus
    // bas) — fourni, on ne l'écrit pas :
    fn matrix_element(&self, kind: MatrixKind, /* … */) -> Result<()> { /* as_domain() puis route */ }

    // ── Déclarations structurelles du bloc *calculé*, une par MatrixKind ;
    // None (défaut) ⇒ pas de terme de ce genre pour cette physique :
    fn stiffness_layout(&self)  -> Option<MatrixLayout> { None }
    fn mass_layout(&self)       -> Option<MatrixLayout> { None }
    fn geometric_layout(&self)  -> Option<MatrixLayout> { None }
    fn tangent_layout(&self)    -> Option<MatrixLayout> { None }
    fn matrix_layout(&self, kind: MatrixKind) -> Option<MatrixLayout> { /* fourni */ }
    // Physique linéaire sans noyau de tangente (ou contrainte) : sa tangente EST
    // sa raideur. Défaut false ⇒ ni tangent_layout ni ceci : la tangente erre.
    fn tangent_is_stiffness(&self) -> bool { false }

    // Contributions telles que l'assembleur les consomme (défaut : Computed(layout)
    // si matrix_layout(kind), sinon — pour Stiffness seulement — Literal(build_stiffness_blocks)) :
    fn contributions(&self, kind: MatrixKind, material: Option<&Handle<SubElementField>>)
        -> Result<Vec<Contribution>> { /* défaut : dérivé de matrix_layout */ }
    fn build_stiffness_blocks(&self, material: Option<&Handle<SubElementField>>)
        -> Result<Vec<SubMatrix>> { /* défaut : dérivé de stiffness_layout + element_matrix */ }

    // ── Forces internes f = ∫ Bᵀ σ (Cast3m BSIG) — le transposé de B :
    fn internal_force_element(&self, geoms: &[CellGeom],
        stress: &SubElementField, fe: &mut [f64]) -> Result<()> { /* défaut : continuum */ }
    fn build_internal_forces(&self, stress: &Handle<SubElementField>)
        -> Result<SubNodeField> { /* fourni : pilote le noyau sur le stiffness_layout */ }

    fn label(&self) -> &'static str;
    fn display(&self) -> String { format!("SubModel<{}>", self.label()) }
    fn render(&self, opts: &DumpOptions) -> String;
}

// Capacités optionnelles — implémentées à part, jamais sur le trait de base.
// Un DOMAINE lit un matériau et intègre sur un espace EF. Rien de plus :
// avoir une loi est l'affaire de `Behavior`, juste en dessous.
pub trait Domain: Sync {
    fn material_fespace(&self) -> Handle<SubFiniteElementSpace>;
    // Les constantes exigées, dans l'ordre où le noyau les indexera ;
    // une liste vide dit « aucune composante contrainte » :
    fn material_components(&self) -> Vec<String> { Vec::new() }
    // Composantes acceptées mais non exigées (alpha…) — cf. plus bas :
    fn optional_material_components(&self) -> &'static [&'static str] { &[] }
    fn behavior_fespace(&self) -> Handle<SubFiniteElementSpace>;
    fn behavior_output_components(&self) -> Vec<String>;
    // Ce que le noyau lit, dans l'ordre de ses indices :
    fn deformation_reads(&self) -> Vec<String>;
    // Et le `B` qui le produit depuis la solution — `Deformation(mesure)`,
    // `Gradient`, `Value`, `Beam`, `Shell(f)` ; requis, `strain` l'exécute :
    fn strain_operator(&self) -> StrainOperator;
    fn state_reads(&self) -> Vec<String> { Vec::new() }
    // L'état au repos, et la question « cette loi exige-t-elle un pas de
    // temps ? » — posées une fois, pas au point de Gauss :
    fn initial_state(&self, material: &SubElementField) -> Result<SubElementField> { /* fourni : des zéros */ }
    fn requires_dt(&self) -> bool { false }
    // La loi de comportement en UN point de Gauss, montage incrémental A → B.
    // Chaque entrée est LA LIGNE de ce point, empruntée au tampon du champ ;
    // `lay` dit où chaque composante s'y trouve (résolu une fois par zone) :
    fn integrate_point(&self, geom: &CellGeom, g: usize, lay: &ZoneLayout,
        deformation: &[f64], prev: &[f64], material: &[f64],
        dt: f64, out: &mut [f64]) -> Result<()>;
    fn integrate_behavior(&self, deformation: &Handle<SubElementField>,
        prev: &Handle<SubElementField>,
        material: Option<&Handle<SubElementField>>,
        dt: f64) -> Result<SubElementField> { /* fourni : résout la zone, pilote integrate_point */ }

    // ── Voie MATRICE, le miroir exact de la précédente ────────────────────
    // Ce que le noyau de matrice lit dans l'état, par genre (défaut : rien —
    // une raideur ou une masse ne lit que le matériau, déjà déclaré ci-dessus) :
    fn element_state_reads(&self, kind: MatrixKind) -> Vec<String> { Vec::new() }
    fn element_layout(&self, kind: MatrixKind, material: &SubElementField,
        state: Option<&SubElementField>) -> Result<ElementLayout> { /* fourni */ }

    // Les noyaux de matrice élémentaire (une cellule) — purs et séquentiels.
    // `geoms` : un CellGeom par espace EF du layout (geoms[0] pour le cas usuel,
    // plusieurs pour un élément multi-quadrature — poutre/coque).
    // `lay` dit où chaque composante se trouve, résolu une fois par zone.
    fn element_matrix(&self, geoms: &[CellGeom], material: &SubElementField,
        lay: &ElementLayout, ke: &mut [f64]) -> Result<()>;                     // ∫ Bᵀ D B  (requis)
    // Les quatre suivants ont un défaut qui erre : « cette physique n'a pas ce
    // terme » — pas de masse, pas de flambement, pas de tangente, pas de couplage.
    fn element_mass(&self, geoms: &[CellGeom], material: &SubElementField,
        lay: &ElementLayout, ke: &mut [f64]) -> Result<()>;                     // ∫ ρ Nᵀ N
    fn element_geometric(&self, geoms: &[CellGeom], material: &SubElementField,
        lay: &ElementLayout, state: &SubElementField, ke: &mut [f64]) -> Result<()>;   // ∫ Gᵀ σ̂ G
    fn element_tangent(&self, geoms: &[CellGeom], lay: &ZoneLayout,
        deformation: &SubElementField, prev: &SubElementField,
        material: &SubElementField, dt: f64, ke: &mut [f64]) -> Result<()>;     // ∫ Bᵀ D_alg B
    fn tangent_point(&self, geom: &CellGeom, g: usize, lay: &ZoneLayout,
        deformation: &[f64], prev: &[f64], material: &[f64], dt: f64,
        d: &mut [[f64; 6]; 6]) -> Result<()>;                          // D_alg en un point
    fn coupling_element(&self, kind: MatrixKind, row_geoms: &[CellGeom],
        col_geoms: &[CellGeom], material: &SubElementField,
        lay: &ElementLayout, ke: &mut [f64]) -> Result<()>;                     // interface
}
pub trait Constraint {
    fn multiplier_mesh(&self) -> &Mesh;
    // Les relations linéaires imposées, sous forme neutre vis-à-vis de la méthode
    // d'imposition (Lagrange ou élimination) — cf. « Une contrainte » plus bas :
    fn relations(&self) -> Result<Vec<Relation>>;
}

Les deux capacités portent chacune une voie, et elles ont la même forme : la physique déclare ce qu’elle lit, la zone le traduit en positions une fois, le noyau indexe. Un noyau ne compare jamais un nom de composante.

déclarerésout (1× par zone)consomme
voie point de Gauss (Behavior)deformation_reads / state_readszone_layout → ZoneLayoutintegrate_point
voie matrice (Domain)material_components / element_state_readselement_layout → ElementLayoutelement_matrix & consorts

Conséquences pratiques — pour donner une capacité à une physique, implémenter le sous-trait et redéfinir le seam correspondant pour rendre Some(self) :

  • Domaine (physique sur une région) : impl Domain + as_domain(). Déclarer material_fespace() (+ material_components()) — l’assembleur (src/ops/matrix.rs) sélectionne et valide le SubElementField automatiquement — et les noyaux de matrice qu’on a. Tous sont fournis et erronent par défaut : une physique peut intégrer sans produire de matrice d’un genre donné, voire aucune.

  • Comportement (une loi au point) : impl Behavior + as_behavior(). Déclarer behavior_fespace() + behavior_output_components() + deformation_reads() + strain_operator() (quel producteur géométrique fabrique cette entrée depuis la solution : c’est ce qu’exécute element_field::strain) + integrate_point(...), la loi de constitution en un point de Gauss. integrate_behavior est fourni : il résout la zone, puis pilote ce noyau en parallèle sur toutes les cellules. Un élément linéaire en a bien une, simplement triviale (N = E·A·ε) ; un transfert de bord, lui, n’en a pas — son h·a est le coefficient de son propre opérateur appliqué en un point, et son résidu suit de l’opérateur.

    Une convention fixe la forme de la loi : la contrainte en B est le matériau de B appliqué à la déformation élastique de B, σ(B) = C(B) : ε_e(B) — jamais σ(A) + C:Δε. C’est ce qui la garde juste quand le matériau dépend de la température, sans que le pas-à-pas ait à corriger quoi que ce soit (détail dans comportement).

    Deux invariants tiennent dans ce noyau, et ils décident de la forme du reste : aucun test que l’amont a déjà tranché — présence d’une composante, forme d’un champ, Option à déballer — et aucune allocation dynamique. Un noyau conforme s’écrit sans un seul ? sur ses lectures. Ce que l’auteur déclare (deformation_reads, state_reads, material_components) est exactement ce qui permet cela : la convention est traduite en positions une fois par zone, dans zone_layout, où un champ fabriqué à la main dans un autre ordre est refusé avec un message qui nomme le champ et l’écart. Les branchements qui restent sont ceux de la physique. La modélisation, elle, ne s’écrit pas ici. Une physique du continu en petites déformations tient un Continuum (src/models/continuum/) au lieu de redéclarer sa géométrie : il porte le sous-espace EF, le support POI1, la dimension et la cinématique, valide les trois cohérences à la construction (élément solide et non variété, cinématique possible dans cet espace, accord géométrie ↔ axisymétrie), et fournit les noyaux element_stiffness, element_mass, element_geometric et element_tangent_from_state ainsi que la nomenclature Voigt. Élasticité, plasticité et endommagement s’en servent toutes trois — c’est ce qui rend le produit modélisation × loi réel plutôt qu’une délégation d’une physique vers sa voisine.

  • Contrainte (multiplicateurs de Lagrange) : impl Constraint (multiplier_mesh() + relations()) + as_constraint(). SubModel::multiplier_nodes() et multiplier_mesh() en découlent. C’est le foyer de la famille contrainte — Dirichlet, Mpc, Embedded, Contact.

  • Terme de masse, raideur géométrique, tangente cohérente : déclarer le *_layout correspondant et écrire le noyau element_* (voir ci-dessous).

  • Tangente d’une physique linéaire : pas de noyau à écrire. Déclarer tangent_is_stiffness() à true suffit, et une demande de tangente assemble alors la raideur de ce sous-modèle, exactement. C’est le cas de la conduction, de Fick, des échanges, des barres, poutres et coques, et de toutes les contraintes, dont les relations sont linéaires. Une physique qui ne déclare ni ceci ni tangent_layout fait échouer la tangente : un bloc nul ferait diverger le Newton loin de la cause.

Une contrainte comme Dirichlet n’implémente que Constraint (plus contributions, cf. ci-dessous) : elle n’a ni element_matrix, ni Domain — leur absence est un fait de compilation, pas une erreur à l’exécution.

La nature : physics()

physics() rend une slice constante de Physics (Mechanical, Thermal, Constraint, Other) : la classification grossière de la physique, orthogonale à l’axe de capacité Domain/Constraint. Elle est requise — chaque physique déclare sa nature à son site de définition, une physique couplée en déclarant plusieurs. Cette information voyage avec chaque bloc assemblé jusqu’à la SubMatrix, et alimente les sélecteurs Model::filter / Matrix::filter, qui matchent par appartenance. Un HeatConduction rend &[Physics::Thermal], un Dirichlet &[Physics::Constraint].

Un genre de matrice = un layout + un noyau

L’assemblage est agnostique au genre de matrice. L’énum MatrixKind (Stiffness, Mass, Geometric, Tangent) est le discriminant qui fait tourner la même machinerie — recette, scatter colorié, cache de motif creux par genre — avec un noyau élémentaire différent :

MatrixKindCast3mintégralelayoutnoyau
StiffnessRIGI / COND∫ Bᵀ D Bstiffness_layoutelement_matrix
MassMASS / CAPA∫ ρ Nᵀ Nmass_layoutelement_mass
GeometricKSIG∫ Gᵀ σ̂ Ggeometric_layoutelement_geometric
TangentKTAN∫ Bᵀ D_alg Btangent_layoutelement_tangent

Ajouter un terme à une physique, c’est donc deux méthodes : le *_layout (souvent le même que celui de la raideur — mêmes espaces EF, même support, mêmes variables : seul le noyau diffère) et le element_*. Une physique sans terme d’un genre ne redéfinit rien : son layout reste None, elle ne contribue pas, et ops::matrix::mass(...) sur un modèle qui la contient l’ignore simplement. Côté opérateurs, un point d’entrée par genre — ops::matrix::{stiffness, mass, geometric, tangent}, plus lump — tous adossés au même assemble_kind.

La raideur géométrique reçoit en plus un state : la contrainte courante, produite par integrate_behavior. C’est le couple producteur/consommateur.

La tangente, elle, ne reçoit aucun champ d’état : D_alg est évalué au point de Gauss par tangent_point, à partir des mêmes entrées que integrate_point. Un champ de modules n’aurait eu que l’assembleur pour lecteur, et il pesait de 6 à 21 réels par point — jusqu’à plus de la moitié de la ligne de comportement en 3-D. Surtout, l’émettre depuis COMP faisait payer sa dérivation à chaque itération : pour les huit lois plastiques sans forme fermée, treize retours radiaux par point au lieu d’un. La tangente se demande donc, en appelant ops::matrix::tangent, et Behavior::tangent_source() dit d’avance ce qu’elle coûtera.

Les forces internes

build_internal_forces(stress) est fourni : il pilote internal_force_element en parallèle sur les espaces EF du stiffness_layout et disperse aux nœuds de son support. Le noyau élémentaire par défaut est celui de la mécanique des milieux continus — f_{i,a} = Σ_g Σ_b (∂N_i/∂x_b) σ_ab, lu en nommage Voigt (sigma_xx, sigma_xy, …), terme de cerceau compris en axisymétrie. Une physique dont le dual n’est pas un vecteur déplacement (thermique, barre, poutre, coque) redéfinit internal_force_element. Pour une loi linéaire, le résultat vaut K·u.

Redéfinir, c’est écrire le transposé du B que la physique intègre déjà dans sa rigidité — non en dériver un second. Les structurels rendent donc ce B explicite et le partagent entre les deux sens : models::beam::b_into pour les poutres, models::shell::b_into pour les coques. tests/internal_forces.rs mesure l’égalité f_int == K·u qui en découle, et elle est exacte : la loi d’un élément structurel est linéaire, il n’y a pas de tolérance physique à choisir.

Le noyau reçoit la géométrie et l’état, jamais le matériau — le B d’un continuum est le gradient symétrique, il ignore tout module. Une physique dont le B dépend, lui, du matériau doit donc lui faire porter ce dont il a besoin : Timoshenko ajoute Φ à son comportement, seule grandeur non conjuguée que ce dépôt garde en état, et elle se justifie parce que le résidu la relit à chaque itération de Newton.

Le parallélisme est gratuit (et invisible)

Les noyaux qu’une physique écrit — integrate_point (un point de Gauss), element_matrix & consorts (la matrice élémentaire d’une cellule), internal_force_element — sont séquentiels et purs : ils ne voient ni rayon, ni un handle, ni un verrou. Les drivers de models::kernel portent la parallélisation et le zéro-copie au-dessus d’eux. Voir Parallélisme.

Concrètement, une physique de continuum déclare son layout (espaces EF, support, variables, ordering) : le défaut de contributions() en tire une Contribution::Computed, et l’assembleur global bâtit un bloc calculé puis disperse le noyau élémentaire directement dans le CSR, en parallèle par coloration des cellules — sans matérialiser de COO. La voie littérale (build_stiffness_blocks) est le second défaut du trait, dérivée du même couple stiffness_layout + element_matrix via kernel::assemble_block ; elle sert de référence d’équivalence et de repli, mais une physique volumique ne l’écrit plus.

Une contrainte comme Dirichlet (aucun layout, rien d’intégré sur une cellule) redéfinit directement contributions() : elle rend Vec::new() pour tout genre autre que Stiffness, et ses blocs C / Cᵀ en Contribution::Literal pour celui-là — l’assembleur reste sans aucun cas particulier « Dirichlet ».

Un bloc inter-maillages : Coupling

Une physique d’interface (l’échange h(c₁ − c₂) entre deux corps qui ne partagent pas leurs nœuds) a besoin de blocs dont les lignes vivent sur un maillage et les colonnes sur un autre. C’est la troisième variante, Contribution::Coupling(CouplingLayout).

Tout ce qui est sous ce seam était déjà asymétrique lignes/colonnes : SubMatrix::computed prend deux supports, et le scatter comme les drivers de noyau les passent séparément. Le seul point qui les confondait était le champ unique MatrixLayout.support — d’où un layout séparé plutôt qu’un champ de plus, qui aurait touché les treize physiques existantes pour un besoin qu’aucune n’a :

///
/// ```
/// # use pyrucast::models::symmetry::MaterialSymmetry;
/// # use pyrucast::containers::matrix::Symmetry;
/// # 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, SubModel};
/// # use pyrucast::coords::Coords;
/// # use pyrucast::handle::Handle;
/// # use pyrucast::models::{Constraint, Domain, MatrixKind, RelationSense, SubModelKind};
/// # use pyrucast::ops::mesh;
/// # let coords = Handle::new(Coords::new(2).unwrap());
/// # let n: Vec<Node> = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]
/// #     .iter().map(|p| Node::create_in(coords.clone(), p).unwrap()).collect();
/// # let mut sm = SubMesh::new(coords.clone(), ElementType::TRI3);
/// # sm.add_cell(&[n[0].id(), n[1].id(), n[2].id()]).unwrap();
/// # let fes = FiniteElementSpace::lagrange1(&Mesh::from_submesh(sm)).unwrap();
/// # let zone = fes.get(0).unwrap();
/// # let impose = mesh::poi1_from_nodes(&n[..1]).unwrap();
/// # let volume = SubModel::heat_conduction(zone.clone(), MaterialSymmetry::Isotropic).unwrap();
/// # let cible = pyrucast::ops::model::heat_conduction(&fes, MaterialSymmetry::Isotropic).unwrap();
/// # let appui = SubModel::dirichlet(&impose, &cible, "T", RelationSense::Equality).unwrap();
/// # use pyrucast::containers::matrix::DofOrdering;
/// # use pyrucast::models::CouplingLayout;
/// // What an **interface** law describes: row *and* column subspaces, on
/// // two facing meshes, where a [`MatrixLayout`] holds on a single
/// // support. Conformity is checked when the block is built, and
/// // **reported** rather than approximated: an interface that does not
/// // match is a meshing problem.
/// let l = CouplingLayout {
///     fespaces: vec![zone.clone()],
///     col_fespaces: vec![zone.clone()],
///     row_support: zone.read().submesh().read().to_poi1()?,
///     col_support: zone.read().submesh().read().to_poi1()?,
///     dual_vars: vec!["q".into()],
///     primal_vars: vec!["T".into()],
///     ordering: DofOrdering::NodesThenVars,
///     // An inter-mesh block is never symmetric alone; a real exchange law
///     // declares its two off-diagonal blocks a `Half` pair instead.
///     symmetry: Symmetry::None,
/// };
/// assert_eq!(l.fespaces.len(), l.col_fespaces.len());
/// # Ok::<(), pyrucast::PyrucastError>(())
/// ```
pub struct CouplingLayout {
    /// FE subspaces carrying the **rows** (the primary drives the cell loop).
    pub fespaces: Vec<Handle<SubFiniteElementSpace>>,
    /// FE subspaces carrying the **columns**, on the facing mesh.
    pub col_fespaces: Vec<Handle<SubFiniteElementSpace>>,
    /// POI1 sub-mesh giving the block's row node sequence.
    pub row_support: Handle<SubMesh>,
    /// POI1 sub-mesh giving the block's column node sequence.
    pub col_support: Handle<SubMesh>,
    /// Row variable names (dual).
    pub dual_vars: Vec<String>,
    /// Column variable names (primal).
    pub primal_vars: Vec<String>,
    /// `(node_local, var)` ↔ matrix-index ordering.
    pub ordering: DofOrdering,
    /// What share of the matrix's symmetry the block carries. An inter-mesh
    /// block is never symmetric alone — its rows and columns live on facing
    /// meshes — so an exchange law declares its two off-diagonal blocks a
    /// [`Symmetry::Half`] pair, exactly as a constraint declares `C` and `Cᵀ`.
    pub symmetry: Symmetry,
}

Pas de champ symmetric : un bloc de couplage n’est jamais symétrique seul — seule la réunion des quatre l’est, comme la paire C / Cᵀ.

Le noyau correspondant est coupling_element(kind, row_geoms, col_geoms, material, ke) : il reçoit deux CellGeom, la maille du côté ligne et la maille en vis-à-vis du côté colonne. Le driver kernel::coupling_block_triplets_per_cell parcourt les deux connectivités en pas à pas ; il exige des maillages conformes (même type d’élément, même nombre de mailles, maille i face à maille i) et le signale sinon.

C’est aussi le noyau qui porte le signe — +h∫NᵢNⱼ en diagonale via element_matrix, −h∫NᵢNⱼ hors diagonale via coupling_element — puisque chaque bloc choisit son noyau depuis sa propre variante de contribution. L’assembleur n’a rien à savoir des interfaces.

Une réserve de mise en œuvre : le scatter d’un bloc de couplage est séquentiel (ses matrices élémentaires restent, elles, calculées en parallèle). Le coloriage qui rend le scatter parallèle sûr repose sur une connectivité ; avec deux, il ne prouve plus rien. Une interface porte un maillage de bord — c’est sans effet mesurable, et cela évite d’inventer un coloriage à deux côtés pour un gain nul.

Voir interface_transfer, son premier utilisateur.

Le champ fespaces du MatrixLayout est un Vec : un seul espace EF pour une physique de continuum, ou plusieurs — partageant un maillage, ne différant que par la quadrature — pour un élément multi-quadrature. C’est ce que fait la coque de Reissner-Mindlin (fespaces: vec![full, shear], membrane et flexion en Gauss complet + cisaillement transverse réduit, contre le blocage) : element_matrix reçoit alors deux CellGeom, geoms[0] pour la membrane et la flexion, geoms[1] pour le cisaillement, et l’élément passe par le même chemin de scatter parallèle que le reste — la sparsité ne dépendant que de la connectivité, pas de la quadrature.

Le second espace est construit par la physique, pas reçu en argument : il est entièrement déterminé par le premier, et les deux CellGeom doivent désigner la même maille, un invariant qu’on préfère établir plutôt que vérifier. Une même physique peut d’ailleurs en déclarer un nombre variable selon sa formulation — la coque en Kirchhoff discret n’en déclare qu’un, n’ayant aucun cisaillement à intégrer.

Une contrainte : les relations, forme neutre

Constraint::relations() rend une Relation par nœud multiplicateur : son multiplier_node (créé par la contrainte, un par relation), la liste des termes (node, variable, target_dual, coefficient), et un sense (RelationSense::Equality par défaut, GreaterEqual / LessEqual pour l’unilatéral, cf. Contact).

C’est la source unique de vérité : contributions() en tire les blocs C / Cᵀ — via le helper partagé constraint_block_pair, qui pose aussi le sens des relations sur le bloc C (SubMatrix::set_row_sense) — et les forces internes (Cᵀλ, C·u) les appliquent. La résolution, elle, ne lit plus que la matrice : la ligne imposed est la relation, ses coefficients sont la ligne, son sens est celui du bloc ; multiplicateurs, ensemble actif ou élimination s’en servent tous trois. Une nouvelle contrainte n’a donc à décrire ses relations qu’une fois, et à poser leur sens sur son bloc C si elles sont unilatérales.

Le comportement : le montage incrémental A → B

integrate_point intègre le pas A → B en un point de Gauss :

  • deformation — la cinématique de fin de pas ε(B), produite par un opérateur géométrique (gradient, deformation, beam_deformation) ;
  • prev — l’état convergé au début du pas A : le flux/contrainte σ(A), les variables internes VAR(A), et pour les lois incrémentales la cinématique ε(A). Il existe toujours : au premier pas, c’est l’état de repos que fabrique initial_state (zéro partout, sauf ce que la loi fait partir d’une constante du matériau) ;
  • material — les données matériau du point, vides pour une physique qui n’en déclare pas ;
  • dt — l’incrément de temps, 0.0 pour une loi indépendante du temps ; une loi qui en a besoin le reçoit toujours, integrate_behavior refusant l’intégration sinon.

Le noyau écrit dans out les composantes déclarées par behavior_output_components() : l’état matériau en B — σ(B), VAR(B), et l’éventuelle ε(B) recopiée. La sortie devient le prev du pas suivant. La tangente cohérente n’y est pas : tangent_point l’évalue au point, à la demande de matrix.tangent.

Une composante matériau facultative

Un coefficient annexe, consommé par un opérateur tiers et non par l’assemblage (typiquement alpha, la dilatation thermique lue par ops::element_field::thermal_strain), se déclare dans optional_material_components() : il traverse le canal matériau s’il est fourni, mais n’est jamais exigé à l’assemblage — seules les composantes requises discriminent la zone matériau. Ce n’est donc jamais un argument scalaire d’un opérateur.

Le dispatch — src/containers/model.rs

#[derive(Serialize, Deserialize)]
pub enum SubModel {
    HeatConduction(heat_conduction::HeatConduction),
    Dirichlet(dirichlet::Dirichlet),
    // … une ligne par physique
}

impl SubModel {
    pub fn as_kind(&self) -> &dyn SubModelKind {
        match self {
            SubModel::HeatConduction(p) => p,
            SubModel::Dirichlet(p) => p,
            // … une ligne par physique
        }
    }
}

Debug, Display, Dump, les méthodes déléguantes de SubModel et l’assembleur appellent tous self.as_kind().<méthode>() — ils sont écrits une fois pour toutes.

Ce qui est générique (rien à toucher)

  • src/ops/matrix.rs : stiffness() / mass() / geometric() / tangent() délèguent tous à assemble_kind(), qui boucle sur contributions(kind, …) et pilote le matériau via le seam as_domain() (Domain). Aucun match par variante.
  • src/ops/element_field/behavior.rs et material_field.rs, avec leur wrapper src/py/ops/element_field.rs.
  • src/ops/node_field/internal_forces.rs : passe par build_internal_forces().
  • src/py/ops/matrix.rs : les assembleurs délèguent à model.inner.

Pour finir

Régénérer le stub python/pyrucast/_pyrucast/__init__.pyi (cargo run --bin stub_gen --features stub-gen, venv activé), puis builder + tester avant de commiter — script/check_all.sh pour la passe complète, ou le seul bloc concerné (check_rust.sh, check_doc.sh…).


Pourquoi ça passe l’échelle

Avec des dizaines de physiques, deux propriétés comptent : le coût d’ajout et la persistance.

Coût d’ajout : O(1) fichier

Le comportement d’une physique est co-localisé dans son fichier (struct

  • impl SubModelKind + les impl de capacité qui la concernent). Ajouter la physique n°30 ne touche que 4 endroits (§ Les étapes), dont 2 sont des lignes uniques dans model.rs. Aucune des méthodes génériques n’est modifiée. C’est l’inverse du « shotgun surgery » qu’imposerait un enum où chaque méthode ferait son propre match : là, ajouter une physique forcerait à éditer une dizaine de sites.

La même propriété tient sur l’autre axe : ajouter un genre de matrice a coûté un variant de MatrixKind, un *_layout et un element_* par physique concernée — l’assembleur, le cache de motif et le scatter n’ont pas bougé.

Les deux lignes (variante + bras de as_kind) pourraient même être générées par une macro physics_enum! { HeatConduction, Dirichlet, … } pour ne laisser qu’une seule déclaration.

Pourquoi garder l’enum (et pas Box<dyn SubModelKind>)

La persistance utilise bincode sur des Serialize/Deserialize dérivés (src/persist.rs), un format non auto-descriptif. Or :

  • un enum SubModel se sérialise nativement (indice de variante + payload), zéro code manuel ;
  • un Box<dyn SubModelKind> imposerait typetag, qui ne supporte pas les formats non auto-descriptifs comme bincode. On perdrait la persistance.

L’enum donne aussi l’exhaustivité : le compilateur refuse d’oublier un cas dans as_kind(). On obtient donc le meilleur des deux mondes — sérialisation triviale et exhaustivité de l’enum, comportement co-localisé et coût d’ajout constant du trait.

Et les données neutres partagées ?

Une donnée commune à toutes les physiques (un name, un flag enabled, une pondération) se traite selon sa nature :

  • dérivable (calculable à partir du type/de l’état) → un défaut dans le trait SubModelKind la fournit gratuitement à toutes les physiques, ex. fn weight(&self) -> f64 { 1.0 }. Le trait « l’impose et l’implémente automatiquement ». C’est exactement le statut de physics(), à ceci près qu’elle est volontairement sans défaut : la nature ne se devine pas, on veut que chaque physique la déclare.
  • stockée et mutable (saisie à l’exécution) → un trait ne peut pas porter de champ ni en générer un par défaut : il imposerait un accesseur fn meta(&self) -> &Meta, mais chaque struct devrait alors stocker le champ (le boilerplate par-physique que la fusion supprime). Le bon foyer redevient alors un wrapper struct SubModel { kind, meta } — à ré-introduire si et seulement si ce besoin apparaît (cf. la discussion dans le chapitre Modèle physique).

Bilan

  • Format de persistance : enum + bincode, stable.
  • Coût d’ajout : O(1) fichier, ~2 lignes de câblage.
  • Comportement : co-localisé par physique.
  • Le seul match par variante du module modèle est as_kind().

Ajouter un élément fini

Ce chapitre liste tous les points de code à toucher pour ajouter un type d’élément. Comme pour ajouter une physique, le coût est O(1) fichier : il ne dépend pas du nombre d’éléments déjà en place.

Le principe en une phrase

L’énum ElementType ne sert qu’au stockage et à la sérialisation ; tout le comportement vit dans une struct par élément (sous src/atoms/element_kind/) qui implémente le trait ElementKind. Un unique point de dispatch, ElementType::as_kind(), relie les deux. Le code générique — skin, orient, border, les localisateurs, l’export, le rendu — ne fait jamais de match par variante.

ElementType  (enum : stockage + sérialisation bincode + nom cast3m)
├── POI1, SEG2, TRI3, …, HEX27
├── ALL          -> &'static [ElementType]   ← la seule énumération
└── as_kind(&self) -> &'static dyn ElementKind   ← l'unique match

ElementKind  (trait : le comportement d'un type d'élément)
├── Identité (requis)
│   ├── element_type / ref_nodes / reversal_permutation / corner_count
│   └── nodes_per_cell, topological_dim, is_quadratic  (fournis, tirés de ref_nodes)
├── Topologie de référence (défaut : rien)
│   └── facets -> &[Facet] / edges -> &[[usize; 2]]
├── Domaine de référence (requis)
│   └── ref_centroid / ref_measure / contains_ref / clamp_ref
├── Interpolation (requis)
│   ├── degree -> Option<Interpolation>
│   ├── shape_into / dshape_into        (formes sans allocation)
│   └── shape / dshape                  (fournis : surcouche allouante)
├── Quadrature
│   ├── gauss -> (Vec, Vec)             (requis)
│   └── reduced                         (fourni : centroïde × mesure)
├── Échange (requis)
│   └── vtk_code / gmsh_code / gmsh_permutation / med_permutation
│                                         (permutations : identité par défaut)
└── Familles (défaut : None)
    └── quadratic / linear_parent / split_into

Les étapes

Ajouter un élément se réduit à trois gestes :

  1. src/atoms/element_kind/<mon_element>.rs (nouveau) — une struct unité et son impl ElementKind. Calquer sur tri3.rs (cas linéaire le plus court), tri6.rs (quadratique, qui délègue son domaine à son parent linéaire) ou pyra5.rs (le cas difficile : fonctions de forme rationnelles et quadrature conique).
  2. src/atoms/element_kind/mod.rs — un mod <mon_element>; et un bras dans as_kind().
  3. src/atoms/element_type.rs — une variante dans l’énum, son rustdoc, et une entrée dans ElementType::ALL.

Rien d’autre. En particulier :

  • aucun wrapper PyO3 : ElementType traverse la frontière en chaîne, et from_name se déduit de ALL + name() ;
  • nodes_per_cell, topological_dim et from_name sur ElementType délèguent au trait, et n’ont donc pas de bras à compléter ;
  • skin, orient, border, convert, to_quadratic, l’export VTK, la lecture gmsh, l’échange MED, le rendu et la subdivision colorée sont génériques et ne changent pas — à condition que gmsh_permutation et med_permutation disent comment ces formats numérotent les nœuds de la maille. Pour un volume, la numérotation MED se vérifie contre medcoupling : tests/python/test_medcoupling.py exige un volume positif et des nœuds milieux au milieu des arêtes qu’il en déduit ; ajouter le type à sa table REFERENCE.

Reste à écrire la doc : une fiche book/src/elements/<nom>.md, une entrée dans SUMMARY.md, une ligne dans le catalogue d’elements/index.md, et une ligne dans la table de correspondance gmsh d’operateurs/maillage.md.

Les deux figures de la fiche (élément de référence, quadrature) se génèrent : déclarer l’élément dans ELEMENTS de script/elements_figures.py (sa règle de quadrature, et ses faces s’il est 3-D), puis lancer python script/elements_figures.py. Les nœuds sont lus dans la table de la fiche. tests/python/test_doc_elements_figures.py échoue si un SVG est périmé ou si la règle dessinée diffère de celle de pyrucast.

Ce que le trait demande, et pourquoi

L’élément de référence

ref_nodes() est la donnée racine : les coordonnées de chaque nœud dans le repère \( \xi \), dans l’ordre local. Le nombre de nœuds et la dimension topologique s’en déduisent, et c’est la vérité contre laquelle tous les tests d’invariants recoupent les autres tables.

Fixer d’abord la convention — repère, numérotation locale, orientation (CCW pour les faces) — et la documenter dans le rustdoc de la variante.

La convention des nœuds milieux. Tout le code s’appuie dessus : les coins d’abord, puis un nœud milieu par arête, dans l’ordre de edges(). Le milieu de l’arête k est donc toujours à l’indice local corner_count() + k. QUA9 et HEX27 ajoutent leurs centres de face et de volume après. C’est ce qui permet à to_quadratic, au fil de fer et aux facettes quadratiques de se déduire d’une seule table d’arêtes.

Les facettes

facets() rend les facettes orientées vers l’extérieur — les arêtes d’une surface, les faces d’un volume. Une facette est un élément à part entière : une face de TET10 est un TRI6, une face de HEX27 est un QUA9. Le champ nodes porte donc les nœuds milieux, et Facet::corners() restreint aux coins — ce sur quoi deux mailles voisines s’accordent quel que soit leur degré, donc ce qui sert de clé d’adjacence.

C’est cette seule table qui alimente skin, orient, le culling des faces cachées et la subdivision du rendu.

Le domaine de référence

contains_ref et clamp_ref décrivent le domaine et la projection dessus ; ref_centroid en donne un point intérieur, qui sert à la fois de départ au Newton d’inversion et de point de la quadrature réduite. ref_measure est la mesure du domaine, à laquelle les poids de quadrature doivent sommer.

L’interpolation

degree() déclare le degré Lagrange du type — un TRI6 est quadratique, ce n’est pas un choix. shape_into et dshape_into écrivent dans un tampon fourni : c’est la forme à implémenter, parce que l’inversion de la géométrie (locate_points, project_points) les appelle dans une boucle de Newton, où une allocation par appel dominerait le coût. shape/dshape sont les surcouches allouantes, fournies.

Astuce de validation : recouper les dérivées analytiques par différences finies, ce que fait déjà check_dshape_matches_fd pour tous les types.

Le Jacobien, son déterminant (y compris le cas manifold \( d_s > d_r \)) et les dérivées physiques \( \partial N_i / \partial x \) sont génériques — rien à écrire.

La quadrature

gauss() rend les points et poids de la règle par défaut, calibrée pour intégrer exactement la matrice de masse Lagrange-1 sur un élément droit. La règle réduite ne s’écrit pas : c’est le défaut du trait, un point au centroïde portant toute la mesure.

Les tests que vous obtenez gratuitement

src/atoms/element_kind/mod.rs porte une batterie d’invariants qui boucle sur ElementType::ALL — un type neuf y entre le jour où il est déclaré, sans une ligne de test à écrire :

  • métadonnées cohérentes entre l’énum et le trait ;
  • nœuds milieux exactement au milieu de leur arête ;
  • arêtes bien formées, tout coin sur au moins une arête ;
  • facettes de codimension 1, du même degré que leur parent, géométriquement cohérentes avec ref_nodes ;
  • jeux de coins de facettes distincts ;
  • centroïde intérieur, nœuds de référence dans le domaine, clamp ramenant dedans ;
  • codes VTK et gmsh uniques, permutations gmsh et MED bijectives ;
  • couple linéaire ↔ quadratique réciproque, à coins et arêtes égaux ;
  • somme des poids = mesure de référence ;
  • partition de l’unité, somme des dérivées nulle, Kronecker aux nœuds, dérivées analytiques contre différences finies.

À ajouter à la main : un test de volume sur un élément droit de géométrie connue (containers/finite_element_space/mod.rs en a un par type), et un aller-retour de sérialisation si des données nouvelles apparaissent.

Pourquoi garder l’énum

Même raison que pour les physiques : bincode n’est pas auto-descriptif, et typetag — nécessaire pour sérialiser un Box<dyn ElementKind> — ne le supporte pas. On perdrait la persistance. L’énum donne en prime l’exhaustivité au compilateur sur as_kind(). Le trait n’est jamais sérialisé : as_kind() rend un &'static, reconstruit à la volée depuis la variante.

Ce que ça a coûté, et rapporté

Avant ce découpage, le savoir d’un élément était réparti sur une vingtaine de fichiers et recopié jusqu’à quatre fois : les facettes en trois exemplaires, les nœuds de référence en quatre, le centroïde en trois. Huit de ces tables retombaient sur _ => None, donc s’oubliaient sans un mot du compilateur — et trois bugs en vivaient (skin muet sur PYRA5 et sur tout élément quadratique, un défaut de rendu silencieusement faux, une liste de test à taille figée).

Ce chapitre listait alors un tableau « quel fichier pour quoi ». Il n’en a plus besoin : il n’y a plus de liste de fichiers à parcourir, seulement un trait à remplir.

Interrompre une fonction

Certains opérateurs (mailleurs, solveurs, boucles de raffinement) peuvent tourner longtemps. On veut alors pouvoir les interrompre proprement — typiquement par un Ctrl+C depuis Python, mais aussi par un timeout ou un signal externe depuis un programme Rust. Ce chapitre explique le principe et comment l’implémenter pour un nouvel opérateur.

Pourquoi un Ctrl+C ne suffit pas

Quand une fonction Rust appelée depuis Python tourne dans une longue boucle, elle garde le GIL et ne rend jamais la main à l’interpréteur. Or KeyboardInterrupt n’est levée par Python qu’entre deux bytecodes. Le Ctrl+C (signal SIGINT) est donc bien enregistré, mais il ne se déclenchera qu’au retour de la fonction Rust : pendant le calcul, la combinaison paraît sans effet.

La solution est l’interruption coopérative : la boucle longue vérifie elle-même, à intervalles réguliers, s’il faut s’arrêter.

Le principe : un jeton, pas un détail de frontend

L’écueil serait de faire appeler Python::check_signals directement par l’opérateur — cela couplerait le cœur de calcul à PyO3 et casserait l’usage en Rust pur (cf. Compilation et tests, build sans python-api).

À la place, l’opérateur reçoit un jeton d’interruption abstrait — le trait Cancel du module interrupt — qu’il interroge périodiquement. C’est le frontend qui décide comment l’interruption est signalée :

FrontendJetonDéclencheur
Rust purNoCancel / ()jamais
Rust purAtomicBoolun autre thread / handler ctrlc lève le drapeau
Rust purDeadlinedépassement d’un délai (timeout)
PythonPySignals (dans src/py, gaté python-api)Ctrl+C via Python::check_signals

Le trait est du Rust pur, sans PyO3 :

///
/// ```
/// # use pyrucast::error::PyrucastError;
/// # use pyrucast::interrupt::{Cancel, Deadline};
/// # use std::sync::atomic::{AtomicBool, Ordering};
/// // The token decides **how** the stop is signalled; the operators
/// // n'en savent rien, et restent libres de toute considération de
/// // frontal — Ctrl+C, délai, bouton d'interface.
/// let stop = AtomicBool::new(false);
/// assert!(stop.check().is_ok());
/// stop.store(true, Ordering::Relaxed);
/// assert!(matches!(stop.check(), Err(PyrucastError::Interrupted)));
/// ```
pub trait Cancel {
    /// Poll the cancellation state. Called frequently, so keep it cheap.
    fn check(&self) -> Result<()>;
}

L’erreur renvoyée est PyrucastError::Interrupted. Sa conversion vers Python (gatée python-api) produit une vraie KeyboardInterrupt, pas un RuntimeError générique. Le cœur, lui, ne voit que &dyn Cancel : il reste PyO3-free et utilisable depuis un programme Rust.

Implémenter l’interruption dans un opérateur

Trois gestes.

1. Faire passer le jeton et le sonder. Le cœur de calcul prend un &dyn Cancel et l’interroge à chaque tour de boucle (un tour = un événement grossier — un élément, une couche, une itération de solveur — pour que le coût d’un check soit négligeable) :

pub fn pave(/* … */, cancel: &dyn Cancel) -> Result<…> {
    loop {
        cancel.check()?;          // ← point d'interruption
        // … une étape de travail …
    }
}

Sonder une fois par étape grossière suffit ; inutile de throttler par un compteur si chaque tour fait déjà un travail substantiel.

2. Exposer deux formes côté Rust — une simple et une interruptible — pour ne pas imposer un jeton aux appelants qui n’en veulent pas :

pub fn triangulate_surface(contour: &Mesh, et: ElementType, size: Option<f64>) -> Result<Mesh> {
    triangulate_surface_cancellable(contour, et, size, &NoCancel)
}

pub fn triangulate_surface_cancellable(
    contour: &Mesh, et: ElementType, size: Option<f64>, cancel: &dyn Cancel,
) -> Result<Mesh> { /* … boucle qui sonde `cancel` … */ }

Un programme Rust câble alors ce qu’il veut, sans toucher à Python :

#[test]
fn un_jeton_partage_interrompt_le_mailleur() {
    let coords = Handle::new(Coords::new(2).unwrap());
    let coins: Vec<Node> = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
        .iter()
        .map(|p| Node::create_in(coords.clone(), p).unwrap())
        .collect();
    let mut sm = SubMesh::new(coords.clone(), ElementType::SEG2);
    for i in 0..4 {
        sm.add_cell(&[coins[i].id(), coins[(i + 1) % 4].id()])
            .unwrap();
    }
    let contour = Mesh::from_submesh(sm);

    let stop = Arc::new(AtomicBool::new(false));
    // A Ctrl+C handler (the `ctrlc` crate, to add to your own Cargo.toml), a
    // supervising thread, a timeout… all arm the same token:
    //     let s = stop.clone();
    //     ctrlc::set_handler(move || s.store(true, Ordering::Relaxed)).ok();

    let mesh = triangulate_surface_cancellable(&contour, ElementType::TRI3, Some(0.5), &*stop);
    assert!(mesh.is_ok()); // rien n'a armé le jeton : le maillage aboutit

    // Token armed in advance: the mesher stops at the first checkpoint.
    stop.store(true, Ordering::Relaxed);
    let interrompu =
        triangulate_surface_cancellable(&contour, ElementType::TRI3, Some(0.5), &*stop);
    assert!(interrompu.is_err());
    // `Deadline::after(Duration::from_secs(10))` would work just as well.
}

3. Brancher le jeton Python dans la couche FFI (src/py, gatée python-api) — le seul endroit où l’interruption Python rencontre le cœur :

pub struct PySignals<'py>(pub Python<'py>);

impl Cancel for PySignals<'_> {
    fn check(&self) -> Result<()> {
        self.0
            .check_signals()
            .map_err(|_| PyrucastError::Interrupted)
    }
}

Le paramètre py: Python<'_> est injecté par PyO3 et n’apparaît pas dans la signature Python : pyrucast.mesh.triangulate_surface(contour, element_type, size=None) reste inchangée, mais un Ctrl+C l’interrompt désormais.

Lien avec le parallélisme

Le même AtomicBool est le mécanisme naturel pour interrompre un calcul parallèle à mémoire partagée : chaque worker sonde le drapeau, et le thread principal (seul à détenir le GIL côté Python) le lève quand check_signals détecte le Ctrl+C. Poser le trait Cancel dès maintenant prépare ce terrain sans coût supplémentaire.

À retenir

  • L’interruption est coopérative : l’opérateur sonde, le frontend décide.
  • Le cœur reste PyO3-free (&dyn Cancel) → utilisable en Rust pur.
  • PyrucastError::Interrupted → KeyboardInterrupt côté Python.
  • Sonder une fois par étape grossière ; coût nul pour NoCancel (inliné).