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 :
| Structure | Rôle |
|---|---|
Coords | Référentiel de coordonnées de nœuds (plusieurs configurations) |
Node | Accesseur utilisateur d’un nœud, avec protection GC automatique |
SubMesh / Mesh | Cellules d’un même ElementType / union de sous-maillages |
SubNodeField / NodeField | Valeurs par nœud × composante (zone / agrégat) |
SubFiniteElementSpace / FiniteElementSpace | Formulation EF (interpolation + quadrature) / union |
SubElementField / ElementField | Valeurs par cellule × point de Gauss × composante |
SubModel / Model | Physique ou contrainte locale / problème complet |
SubMatrix / Matrix | Matrice creuse dont les lignes/colonnes sont des DOFs nommés (NodeId, inconnue), par ex. l’inconnue "UX" d’un nœud |
SubEvolution / Evolution | Valeur (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 :
pyo3y est une dépendance optionnelle, donc un build par défaut ne tire nipyo3nilibpython. 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ème | Processeur |
|---|---|
| Linux, glibc ≥ 2.17 (manylinux2014) | x86_64 |
| Windows 64 bits | x86_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) oupkg-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 parmaturinen 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 laCoordsporte 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 :
| Zone | Agrégat |
|---|---|
SubMesh | Mesh |
SubFiniteElementSpace | FiniteElementSpace |
SubNodeField | NodeField |
SubElementField | ElementField |
SubModel | Model |
SubMatrix | Matrix |
SubEvolution | Evolution |
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 écrita.union(&b)— voir Agrégat.
Trois niveaux de listing
Tout objet pyrucast se liste à trois niveaux, du plus court au plus complet :
print(x)oustr(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à demax_rowslignes (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,DebugetDump.
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é entreNodeFieldetElementField: 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 active | l’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 configuration | un changement d’indice, rien à renuméroter | un autre objet, et ce qui le référence à reconstruire |
| limite | toutes les configurations ont les mêmes nœuds | des 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 sonNode;coords.acquire(id)rend unNodepour 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 sonCoords;position()— ses coordonnées dans la configuration active ;set_position([x, y, …])— les réécrit, dans la configuration active ;coords()— leCoordsauquel il appartient ;node | node— unMeshPOI1 sur les deux nœuds (la même union|que les agrégats, cf. Agrégat), etmesh | nodeajoute un point à unMeshPOI1 d’une seule zone ;print(n),repr(n)etn.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ération | Sens |
|---|---|
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 | other | union (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 unTypeError(« ‘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 :
- 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.
- Partage, pas copie. Les zones retenues sont celles des opérandes, pas des copies.
- Cohérence vérifiée. Ce qui doit être commun l’est, à l’ajout : par
exemple, deux maillages sur des
Coordsdifférents se refusent (« mismatched Coords »). - Fusion des champs. Pour
NodeFieldetElementField, 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 :
| Écriture | Résultat |
|---|---|
agg | agg | l’union des deux listes |
agg | zone | la zone ajoutée en queue (ignorée si déjà présente) |
zone | agg | la même union, la zone en tête (déplacée si elle y était) |
zone | zone | un 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 etagg.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) :
| Écriture | Résultat |
|---|---|
node | node | un Mesh POI1 d’une zone, sur les deux nœuds |
mesh | node | ajoute 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 : unMesh + Meshlève unTypeError.
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 desTRI3, ou que desQUA4… Chaque maille y est une liste de nœuds d’unCoords; - un maillage (
Mesh) est une liste de zones, toutes sur le mêmeCoords. 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_count | le 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_type | le 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_sealed | la zone est-elle scellée ? |
duplicate() | une copie non scellée |
face_color | la 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.
| Type | Nœuds | Dim. topo. | Cas usuel |
|---|---|---|---|
POI1 | 1 | 0 | liste de nœuds |
SEG2 | 2 | 1 | segment linéaire |
TRI3 | 3 | 2 | triangle linéaire |
QUA4 | 4 | 2 | quadrangle linéaire |
TET4 | 4 | 3 | tétraèdre linéaire |
PYRA5 | 5 | 3 | pyramide linéaire (raccord hexaèdre ↔ tétraèdre) |
PENTA6 | 6 | 3 | prisme linéaire (extrusion d’un TRI3) |
HEX8 | 8 | 3 | hexaèdre linéaire |
SEG3 | 3 | 1 | segment quadratique |
TRI6 | 6 | 2 | triangle quadratique |
QUA8 | 8 | 2 | quadrangle quadratique (sérendipité) |
QUA9 | 9 | 2 | quadrangle biquadratique (Lagrange complet, nœud central) |
TET10 | 10 | 3 | tétraèdre quadratique |
PENTA15 | 15 | 3 | prisme quadratique (sérendipité) |
HEX20 | 20 | 3 | hexaèdre quadratique (sérendipité) |
HEX27 | 27 | 3 | hexaè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_cellincrémente le compteur de chaque nœud et annule les incréments déjà faits si l’un échoue ;add_cell_takingadopte des références que l’appelant possède déjà (un nœud tout juste créé) ; leDropde la zone les rend toutes, une fois, quand le dernierHandledisparaît (cf. Modèle mémoire). - Construction en bloc.
add_cellprend le verrou d’écriture duCoordset 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, etCoords::add_nodescré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_takingetremap_nodesrendent alorsPyrucastError::MeshSealed. - Caches. Deux
OnceLock, lus parnode_index()etto_poi1(), vidés par chaque mutation de la connectivité et marqués#[serde(skip)]. - Types d’éléments.
ElementTypeporte 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 danssrc/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
| interpolation | mailles | ce qu’elle interpole |
|---|---|---|
"LAGRANGE1" (défaut des linéaires) | linéaires : SEG2, TRI3, QUA4, TET4, PYRA5, PENTA6, HEX8 | une valeur par nœud |
"LAGRANGE2" (défaut des quadratiques) | quadratiques : SEG3, TRI6, QUA8, QUA9, TET10, PENTA15, HEX20, HEX27 | une valeur par nœud, sommets et nœuds milieux |
"HERMITE3" | SEG2 | une valeur et une pente par nœud : la poutre d’Euler-Bernoulli |
"MODEL_EMBEDDED" | toutes | rien : la physique porte ses propres fonctions, comme la poutre de Timoshenko |
| quadrature | points 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_choicesqui 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 desTRI3,"HERMITE3"ailleurs que sur desSEG2) ; - des mailles de dimension supérieure à celle des
Coords(desTRI3dans desCoords1-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, quadrature | le type de maille et les deux choix de la zone |
submesh() | sa zone de maillage, partagée |
ref_dim, space_dim, nodes_per_cell | la dimension de l’élément de référence, celle des Coords, les nœuds par maille |
is_axisymmetric | si les Coords décrivent un corps de révolution |
cell_count, gauss_count | les 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éthode | longueur | case |
|---|---|---|
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 (unHEX27à 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 :
| type | repè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 :
| type | parent | nœuds ajoutés, arête \( (a,b) \) |
|---|---|---|
SEG3 | SEG2 | nœud 2 sur \( (0,1) \) (\( \xi = 0 \)) |
TRI6 | TRI3 | 3 sur \( (0,1), (1,2), (2,0) \) |
QUA8 | QUA4 | 4 sur \( (0,1), (1,2), (2,3), (3,0) \) |
QUA9 | QUA4 | les 4 arêtes de QUA8, puis un nœud central 8 en \( (0,0) \) |
TET10 | TET4 | 6 sur \( (0,1), (1,2), (2,0), (0,3), (1,3), (2,3) \) |
PENTA15 | PENTA6 | 9 : bas \( (0,1),(1,2),(2,0) \), haut \( (3,4),(4,5),(5,3) \), verticales \( (0,3),(1,4),(2,5) \) |
HEX20 | HEX8 | 12 : 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) \) |
HEX27 | HEX8 | les 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
TRI6par 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 :
| interpolation | géométrie | champ |
|---|---|---|
"LAGRANGE1" / "LAGRANGE2" | Lagrange | la même |
"HERMITE3" | Lagrange-1 | Hermite, 4 fonctions |
"MODEL_EMBEDDED" | Lagrange | aucune |
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érateur | ce qu’il interpole |
|---|---|
model.flux | la fonction test d’une charge répartie |
element_field.deformation | le déplacement, pour la déformation |
element_field.shell_deformation | le déplacement d’une coque |
element_field.interp_to_gauss | un champ aux nœuds, porté aux points de Gauss |
measure.integral | le 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ègle | exactitude polynomiale |
|---|---|---|---|
SEG2 | 2 | Gauss-Legendre sur \([-1, +1]\) : \( \xi_g = \pm 1/\sqrt{3} \), \( w_g = 1 \) | \( \deg \le 3 \) |
TRI3 | 3 | Hammer, 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 \) |
QUA4 | 4 | produit 2×2 de Gauss-Legendre : \( \xi_g = (\pm 1/\sqrt{3}, \pm 1/\sqrt{3}) \), \( w_g = 1 \) | \( \deg \le 3 \) par direction |
TET4 | 4 | Hammer : \( \alpha = \tfrac{5 - \sqrt{5}}{20} \), \( \beta = \tfrac{5 + 3\sqrt{5}}{20} \), points permutés, \( w_g = 1/24 \) | \( \deg \le 2 \) |
PYRA5 | 8 | produit 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 \) |
PENTA6 | 6 | produit 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 \) |
HEX8 | 8 | produit 2×2×2 de Gauss-Legendre : \( \xi_g = (\pm 1/\sqrt{3})^3 \), \( w_g = 1 \) | \( \deg \le 3 \) par direction |
SEG3 | 3 | Gauss-Legendre 3 points | \( \deg \le 5 \) |
TRI6 | 6 | règle symétrique de degré 4 (Dunavant) | \( \deg \le 4 \) |
QUA8 | 9 | produit 3×3 | \( \deg \le 5 \) par direction |
QUA9 | 9 | produit 3×3 | \( \deg \le 5 \) par direction |
TET10 | 11 | règle de Keast de degré 4 (un poids négatif) | \( \deg \le 4 \) |
PENTA15 | 18 | produit TRI6 × Gauss 3 points en \( \zeta \) | \( \deg \le 4 \), \( \le 5 \) en \( \zeta \) |
HEX20 | 27 | produit 3×3×3 | \( \deg \le 5 \) par direction |
HEX27 | 27 | produit 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, SEG3 | 1 | 1, 2, 3 |
TRI3, TRI6, QUA4, QUA8, QUA9 | 2 | 2, 3 |
TET4, TET10, PYRA5, PENTA6, PENTA15, HEX8, HEX20, HEX27 | 3 | 3 |
\( 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
Interpolationet saQuadratureRule, la dimension desCoordset leur caractère axisymétrique, lus une fois à la construction (unCoordsn’en change pas) ; - les tables de référence, en vecteurs plats :
gauss_xi,gauss_w,n_at_g,dn_at_gpour la géométrie, etfield_n_at_g,field_dn_at_g,field_d2n_at_gpour 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)etfield_d2n_at_g(g)lisent la base qui interpole l’inconnue. Sous Lagrange, elles rendent les tables géométriques ; sousModelEmbedded, elles lèvent une erreur qui nomme la situation ;field_d2n_at_gn’existe que sousHermite3. - 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 parjacobianoudn_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 unCellGeomqui 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_west le seul endroit où se décide la mesure, facteur \( 2\pi r \) compris, etCellGeom::field_n_at_gcelui 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, etchamp.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 :
- 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.
- 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 - brendb, et non-b, sur une composante que seulbporte. C’est ce qui permet de ne toucher qu’à une composante :u + du, oùdune porte queu_x, laisseu_ytel quel, etu * f, oùfne porte queu_x, ne multiplie queu_x; - le résultat garde les zones de
a, dans l’ordre. Une composante que seulbporte rejoint la zone deasur ce support quandan’en a qu’une ; sinon elle garde une zone à elle, comme une zone debdont le support n’existe pas dansa.
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, …),restrictet 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 marchera - betrestrict(…, 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_valueet 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()etsame_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.SubNodeFieldetSubElementFieldl’implémentent.SubNodeFieldfournit en plusrow_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 toutAggregatedont la zone est unSubField:NodeFieldetElementFieldle 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_fieldetpscal_fieldassocient les valeurs par support et par composante.- Règles de cohérence.
add_subrefuse une zone qui porterait une composante déjà portée sur son support : c’est le crochetcheck_add, qu’une union ne passe pas, puisque sonfinalizevé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, parmerge_components) etimpl_field_ops!(deux agrégats parmerge_field, un agrégat et un scalaire parcombine_scalar). Combiner peut échouer, sauf une zone avec un scalaire : ces opérateurs rendent unResult. 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)etmerge_field(&autre, f64::powf)font ce que fait**en Python. - Support.
same_supportcompare lesHandlepar identité (Handle::same_object). - Mêmes composantes exigées.
check_same_componentsrefuse deux zones dont les supports ou les composantes diffèrent. L’interpolation d’uneEvolutionl’appelle avantmerge_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_componentstraduit, une fois par zone, une liste de noms en rangs dans la zone, etresolve_optional_componentsmarqueABSENT_COMPONENTles noms que la zone ne porte pas. - Fonctions mathématiques. Les traits
MapValuesetPscaldonnent 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::viewprend 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.minetmaxdonnent le même résultat quel que soit le nombre de threads ;sum,xtxetdotpeuvent 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 porteTsur tout le domaine etux,uysur 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_sub | l’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_count | le 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_count | ses 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_poi1en 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 :
valueetvalueslisent la première zone qui porte le nœud ;sum,node_count,pyrucast.measure.xtxetxtycomptent un nœud partagé une fois ;add_to_componentet 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) :
| Famille | Opérateurs |
|---|---|
| géométrie | positions(maillage), les coordonnées des nœuds, une composante par axe |
| d’un champ aux points de Gauss | divergence, sum_to_nodes |
| assemblage | internal_forces, external_forces |
| d’un champ aux nœuds | restrict, 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(danspyrucast.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 depyrucast.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 desolveet aux sorties dedivergenceet desum_to_nodessur 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 traitsSubFieldetField(src/containers/field.rs), partagés avec le champ aux points de Gauss. - Ligne d’un nœud.
SubNodeField::index_ofpasse parSubMesh::node_index(), unOnceLock<HashMap<NodeId, usize>>du support ;index_of_withprend un verrou de lecture déjà tenu, pour une boucle qui cherche beaucoup de nœuds. - Lecture par les opérateurs.
NodeFieldViewtient 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_rowsparcourt les zones dans l’ordre et donne chaque nœud une fois, à la première zone qui le porte : c’est ce qu’utilisentnode_ids,node_countet 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_count | les 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 ** bentre 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 + zonene 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, etchamp >= srend 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) :
| Famille | Opérateurs |
|---|---|
| matériau | material_field(modele, [(nom, valeur)…]), material_field_per_sub_model, sub_material_field |
| d’un champ aux nœuds | interp_to_gauss, gradient, deformation, beam_deformation, shell_deformation, strain |
| comportement | integrate_behavior, elastic_stress, elastic_strain, thermal_strain, stress_measure, rotate_frame |
| d’un champ aux points de Gauss | cell_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 traitsSubFieldetField(src/containers/field.rs), partagés avec le champ aux nœuds. - Support.
SubField::same_supportcompare lesHandlepar identité (same_object) : c’est ce qui fait de deux espaces sur un même maillage deux supports distincts. - Durée de vie. Le
Handlede l’espace est unArc: la zone le garde en vie, l’espace garde sonSubMesh, qui garde sonCoordset compte ses nœuds (cf. Modèle mémoire). - Coloration. Un
OnceLocksurSubFiniteElementSpace, rempli parcoloring(…)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 :
| Couche | Objets | Ce qu’elle décrit |
|---|---|---|
| Géométrie | Mesh / SubMesh | les nœuds et les mailles |
| Interpolation | FiniteElementSpace / SubFiniteElementSpace | les fonctions de forme et les points de Gauss |
| Physique | Model / SubModel | les é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_subs | l’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_contact | les 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_behavior | vrai 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.…) | Inconnues | Duales | Page |
|---|---|---|---|
heat_conduction(fes, symétrie) | T | q | Thermique |
radiation(fes, cible) | T | q | Rayonnement |
fick(fes, espèce, symétrie) | c_<espèce> | j_<espèce> | Diffusion |
boundary_transfer(fes, cible, variables) | les variables | celles de la cible | Échanges |
interface_transfer(côté_a, côté_b, cible, variables, tol) | les variables | celles de la cible | Échanges |
flux(fes, cible, duale) | aucune | duale ; "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_z | f_w, m_theta ; f_x, f_y, m_z ; f_x…f_z, m_x…m_z | Euler-Bernoulli, Timoshenko |
shell(fes, formulation) | u_x…u_z, r_x…r_z | f_x…f_z, m_x…m_z | Coques |
dirichlet(maillage, cible, variable, sens) | lambda | imposed | Dirichlet |
mpc(termes, cible, sens) | lambda | imposed | Multi-points |
embedded(immergé, hôte, cible, variables, tol) | lambda | imposed | Baignage |
contact(esclave, maître, cible, variables) | lambda | imposed | Contact |
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.Ta pour dualeq,u_xa pour dualef_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 valeurgest écrite à son nœud-multiplicateur, dans la composanteimposed. Le modèle doit porter une seule contrainte ;constraint_rhs_by_indexdé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 :
| Nature | Sous-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 / dtet-Krendent une matrice neuve, assemblée siKl’était, aux valeurs multipliées. Ses blocs sont des copies qui portent le facteur (factor) :Kn’est pas touchée. Diviser par zéro lèveZeroDivisionError, par un nombre infini ounan,ValueError.A + BetA - Badditionnent 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 defilter) 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 dansK, etMne contribue rien à ses lignes.A + Brange d’abord les lignes deA, puis celles que seuleBapporte. K | KestK: les mêmes blocs une seule fois, sans valeurs.K + Kvaut2K. On compose une matrice de morceaux distincts avec|, on additionne deux matrices avec+.K * x, oùxest un champ aux nœuds, est le produit :xest 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
CetCᵀ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 tableTqui exprime chaque inconnue éliminée par les autres, la matrice sans ses contraintes, et la factorisation du système réduitK̂ = 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,Nonetant 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 desArcpartagés avec le motif du modèle (AssemblyPattern) ; seules les valeurs lui appartiennent.factorization, ce que garde le solveur, dans unparking_lot::Mutex<Option<Arc<dyn Any + Send + Sync>>>.dyn Anyaccepte n’importe quel type, si bien quecontainersne dépend pas du solveur :cached_factorization::<T>()le rend s’il est de typeT, etstore_factorizationle range. LeMutexpermet de le remplir depuissolve(&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_supportetcol_support, deuxHandle<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 tablenode_indexdu 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_varsetordering(DofOrdering) : la ligne d’un couple (nœud, nom) estrang_du_nœud * n_noms + rang_du_nomenNodesThenVars,rang_du_nom * n_nœuds + rang_du_nœudenVarsThenNodes;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, leMatrixKindet lesKernelInputs, 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, etrow_sense, le sens (égalité ou inégalité) des lignesimposed, que lit le solveur ;sealed, posé quand une matrice qui contient le bloc reçoit ses valeurs (assemblage ou somme), et lu paradd_entry. Il se pose comme celui d’une zone de maillage : la fonctionseallit 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
SubEvolutionest une courbe : la liste de ses abscisses et la liste des valeurs en regard. L’interpoler enxrend une valeur ; - une
Evolutionréunit desSubEvolution, une par zone, comme unNodeFieldréunit ses zones. L’interpoler interpole chaque courbe et regroupe les résultats : unNodeFieldou unElementFieldpour 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 :
| Politique | Hors 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
- On cherche les deux groupes qui encadrent
T = 300: 20 °C et 500 °C. - 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. - On interpole entre ces deux résultats le long de T :
t = (300 − 20)/(500 − 20) ≈ 0,583, et360 + 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. Sansabscissa_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, unVec<f64>plat :dimsnombres 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, unVec<SubValue>, une valeur par échantillon.SubValuevautScalar(f64),Node(SubNodeField)ouElement(SubElementField): les champs sont rangés dans la courbe, et leur support est unHandlepartagé ;out_of_range(OutOfRange),abscissa_type(Vec<String>) etordinate_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 :
| tag | physiques |
|---|---|
thermal | conduction ; rayonnement, qui porte aussi radiation |
diffusion | diffusion de Fick |
mechanical | élasticité, plasticité, endommagement, barre, poutres, coques |
constraint | Dirichlet, 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 :
| type | ce qu’il fait | physiques |
|---|---|---|
| domaine avec comportement | intè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 état | conduction, rayonnement, Fick, élasticité, plasticité, endommagement, barre, poutres, coques |
| domaine sans comportement | intègre sur un sous-espace et lit un matériau, sans loi | échange de bord, transfert d’interface, flux imposé |
| contrainte | impose des relations linéaires entre inconnues par des multiplicateurs de Lagrange ; aucun sous-espace, aucun matériau | Dirichlet, multi-points, baignage, contact |
Ce que les accesseurs d’un sous-modèle répondent :
| domaine avec comportement | domaine sans comportement | contrainte | |
|---|---|---|---|
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 requises | les composantes requises | None |
fespace() | le sous-espace où elle intègre | le sous-espace où elle intègre (le côté A d’un transfert d’interface) | None |
has_behavior | True | False | False |
multiplier_mesh() | vide | vide | les 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ùBest 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
| physique | type | raideur | masse | géométrique | tangente |
|---|---|---|---|---|---|
heat_conduction | avec comportement | conduction | capacité ρ·cp | — | = raideur |
radiation | avec comportement | film linéarisé | — | — | analytique |
diffusion
| physique | type | raideur | masse | géométrique | tangente |
|---|---|---|---|---|---|
fick | avec comportement | diffusion | stockage poro | — | = raideur |
Le tag de la cible
| physique | type | raideur | masse | géométrique | tangente |
|---|---|---|---|---|---|
boundary_transfer | sans comportement | film | — | — | = raideur |
interface_transfer | sans comportement | quatre blocs | — | — | = raideur |
flux | sans comportement | — | — | — | — |
mechanical
| physique | type | raideur | masse | géométrique | tangente |
|---|---|---|---|---|---|
elasticity | avec comportement | oui | oui | oui | analytique |
plasticity_perfect, plasticity_isotropic | avec comportement | élastique | oui | oui | analytique |
drucker_prager, ottosen, gurson | avec comportement | élastique | oui | oui | perturbation |
creep_norton, creep_blackburn, creep_lemaitre, viscoplasticity_chaboche, viscoplasticity_lemaitre_chaboche | avec comportement | élastique | oui | oui | perturbation |
mazars, damage_tc, damage_sic_sic | avec comportement | non endommagée | oui | oui | aucune |
truss | avec comportement | oui | oui | oui | = raideur |
bernoulli, timoshenko | avec comportement | oui | oui | oui, sauf en 1-D | = raideur |
shell | avec comportement | oui | — | — | = raideur |
constraint
| physique | type | raideur | masse | géométrique | tangente |
|---|---|---|---|---|---|
dirichlet, mpc, embedded, contact | contrainte | blocs C et Cᵀ | — | — | = raideur |
Le plan des pages
Chaque physique est décrite dans cet ordre :
- En bref : ce qu’elle modélise, son opérateur Python, son tag, son type, et les éléments qu’elle accepte ;
- Théorie : les équations, de la forme locale à la matrice élémentaire ;
- Variables : primales, duales, matériau, entrée et sortie du comportement, état ;
- Ce qu’elle produit : matrices, tangente, forces internes et externes ;
- Exemple en Python ;
- 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()rendSome(&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()rendSome(&dyn Behavior)pour un domaine qui porte une loi : son entrée (deformation_reads, produite parstrain_operator), sa sortie (behavior_output_components), l’état qu’elle relit (state_reads), sa tangente au point (tangent_point,tangent_source), etrequires_dtpour une loi qui dépend du pas de temps ;as_constraint()rendSome(&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érateur | pyrucast.model.heat_conduction(fespace, symmetry="isotropic") |
| tag | thermal |
| type | domaine avec comportement (label() : "HeatConduction") |
| éléments | SEG2, 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
| primale | T |
| duale | q, le flux de chaleur nodal |
| matériau requis | k ; 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 facultatif | rho, cp, demandés par la capacité seule |
| entrée du comportement | grad_T_x, grad_T_y, grad_T_z : le gradient, un par direction de l’espace |
| sortie du comportement | flux_x, flux_y, flux_z |
| état | aucun |
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 externes | aucune ; 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érateur | pyrucast.model.radiation(fespace, target) |
| tag | thermal et radiation |
| type | domaine avec comportement (label() : "Radiation") |
| éléments | le 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 :
| terme | expression |
|---|---|
| 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
| primale | T, celle de la conduction |
| duale | q, celle de la conduction |
| matériau requis | emis (l’émissivité), T_inf |
| matériau facultatif | sigma |
| entrée du comportement | T, la température aux points de Gauss du bord |
| sortie du comportement | flux, \(\sigma\varepsilon(T^4 - T_\infty^4)\) |
| état | aucun |
Ce qu’il produit
| raideur | le film linéarisé |
| masse | — |
| géométrique | — |
| tangente | analytique, \(4\sigma\varepsilon T^3\) |
| forces internes | le flux rayonné, pondéré par \(N_i\) |
| forces externes | aucune |
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érateur | pyrucast.model.fick(fespace, species, symmetry="isotropic") |
| tag | diffusion |
| type | domaine avec comportement (label() : "Fick") |
| éléments | SEG2, 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 :
| primale | c_H2, la concentration |
| duale | j_H2, le flux d’espèce nodal |
| matériau requis | D_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 facultatif | poro, demandé par le stockage seul |
| entrée du comportement | grad_c_H2_x, grad_c_H2_y, grad_c_H2_z |
| sortie du comportement | j_H2_x, j_H2_y, j_H2_z |
| état | aucun |
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 externes | aucune ; 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érateur | pyrucast.model.boundary_transfer(fespace, target, variables) |
| tag | celui de la cible |
| type | domaine sans comportement (label() : "BoundaryTransfer") |
| éléments | le 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 :
| primale | T, celle de la cible |
| duale | q, celle que la cible associe à T |
| matériau requis | h_T, a_ext_T (la valeur ambiante) |
| comportement | aucun ; les forces internes lisent T interpolée aux points de Gauss |
Ce qu’il produit
| raideur | le 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érateur | pyrucast.model.interface_transfer(side_a, side_b, target, variables, tol=1e-9) |
| tag | celui de la cible |
| type | domaine sans comportement (label() : "InterfaceTransfer") |
| éléments | deux 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.
mpc | interface_transfer | |
|---|---|---|
| inconnues | ajoute lambda | aucune |
| matrice | point-selle | définie positive |
| quand \(h\) grandit | — | la matrice se conditionne mal |
Variables
Pour la variable T :
| primale | T, des deux côtés |
| duale | q, des deux côtés |
| matériau requis | h_T |
| comportement | aucun ; les forces internes lisent le saut jump_T aux points de Gauss |
Ce qu’il produit
| raideur | les 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 externes | aucune |
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érateur | pyrucast.model.flux(fespace, target, dual) |
| tag | celui de la cible |
| type | domaine sans comportement (label() : "Flux") |
| éléments | toute 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
| primale | aucune |
| duale | dual, prise dans la cible |
| matériau requis | phi_<dual> : phi_q, phi_f_x, phi_n |
| comportement | aucun |
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 internes | aucune |
| 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 :
- Élasticité linéaire —
elasticity, et sa symétrie matériau orthotrope ou anisotrope ; - Plasticité de von Mises —
plasticity_perfect,plasticity_isotropic; - Drucker-Prager et Ottosen — des surfaces qui voient la pression ;
- Fluage et viscoplasticité — Norton, Blackburn, Lemaitre, Chaboche, Lemaitre-Chaboche ;
- Endommagement de Mazars ;
- Autres lois d’endommagement — Damage TC, SiC/SiC, Gurson ;
- Grandes transformations — les mesures
de déformation
green_lagrangeethencky.
Éléments de structure, dont les efforts sont intégrés sur la section :
- Barre —
truss, l’effort axial seul ; - Poutre d’Euler-Bernoulli —
bernoulli; - Poutre de Timoshenko —
timoshenko, le cisaillement en plus ; - Coques —
shell, six inconnues par nœud.
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 :
kinematics | espace | composantes 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évolution | xx, yy, zz, xy, où zz est l’orthoradiale \(\theta\theta\) |
"full_3d" | 3-D | xx, 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érateur | pyrucast.model.truss(fespace, strain_measure="linear") |
| tag | mechanical |
| type | domaine avec comportement (label() : "Truss") |
| éléments | SEG2 |
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-D | 2-D | 3-D | |
|---|---|---|---|
| primales | u_x | u_x, u_y | u_x, u_y, u_z |
| duales | f_x | f_x, f_y | f_x, f_y, f_z |
| entrée du comportement | eps_xx | eps_xx, eps_yy, eps_xy | eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy |
| sortie du comportement | n | n | n |
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 requis | E, A |
| matériau facultatif | rho |
| état | aucun |
Ce qu’elle produit
| raideur | \(K_e\) |
| masse | exige rho |
| géométrique | sous 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érateur | pyrucast.model.elasticity(fespace, kinematics, symmetry="isotropic", strain_measure="linear") |
| tag | mechanical |
| type | domaine avec comportement (label() : "Elasticity") |
| éléments | 2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 |
| cinématiques | plane_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_strain | axisymmetric | full_3d | |
|---|---|---|---|
| primales | u_x, u_y | u_x, u_y | u_x, u_y, u_z |
| duales | f_x, f_y | f_x, f_y | f_x, f_y, f_z |
| entrée du comportement | eps_xx, eps_yy, eps_xy | eps_xx, eps_yy, eps_zz, eps_xy | eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy |
| sortie du comportement | sigma_xx, sigma_yy, sigma_xy | sigma_xx, sigma_yy, sigma_zz, sigma_xy | sigma_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 requis | E, nu |
| matériau facultatif | rho, alpha |
| état | aucun |
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_* |
| tangente | analytique : 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 :
symmetry | constantes | ce qu’elle décrit |
|---|---|---|
"isotropic" | 2 | aucune direction privilégiée |
"orthotropic" | 9 | trois plans de symétrie orthogonaux |
"anisotropic" | 21 | le 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
symmetry | maté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érateurs | pyrucast.model.plasticity_perfect(fespace, kinematics, strain_measure="linear") (sans écrouissage), pyrucast.model.plasticity_isotropic(fespace, kinematics, strain_measure="linear") (écrouissage isotrope linéaire) |
| tag | mechanical |
| type | domaine avec comportement (label() : "Plasticity") |
| éléments | 2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 |
| cinématiques | plane_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\) :
- prédiction élastique, \(\sigma^{tr} = D : (\varepsilon_B - \varepsilon^p_A)\), et sa contrainte équivalente \(q^{tr}\) ;
- si \(q^{tr} \le \sigma_y + H\,p_A\), le pas est élastique ;
- 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_strain | axisymmetric | full_3d | |
|---|---|---|---|
| primales | u_x, u_y | u_x, u_y | u_x, u_y, u_z |
| duales | f_x, f_y | f_x, f_y | f_x, f_y, f_z |
| entrée du comportement | eps_xx, eps_yy, eps_xy | eps_xx, eps_yy, eps_zz, eps_xy | eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy |
| contrainte | sigma_xx, sigma_yy, sigma_xy | sigma_xx, sigma_yy, sigma_zz, sigma_xy | sigma_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 requis | E, nu, sigma_y ; plus H pour plasticity_isotropic |
| matériau facultatif | rho, alpha |
| état, écrit après la contrainte | la 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 |
| masse | exige rho |
| géométrique | sous la contrainte sigma_* |
| tangente | analytique, \(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érateurs | pyrucast.model.drucker_prager(fespace, kinematics, strain_measure="linear"), pyrucast.model.ottosen(fespace, kinematics, strain_measure="linear") |
| tag | mechanical |
| type | domaine avec comportement (label() : "Plasticity") |
| éléments | 2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 |
| cinématiques | plane_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_strain | axisymmetric | full_3d | |
|---|---|---|---|
| primales | u_x, u_y | u_x, u_y | u_x, u_y, u_z |
| duales | f_x, f_y | f_x, f_y | f_x, f_y, f_z |
| entrée du comportement | eps_xx, eps_yy, eps_xy | eps_xx, eps_yy, eps_zz, eps_xy | eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy |
| contrainte | sigma_xx, sigma_yy, sigma_xy | sigma_xx, sigma_yy, sigma_zz, sigma_xy | sigma_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 requis | Drucker-Prager : E, nu, friction, k, psi ; Ottosen : E, nu, a, b, k_1, k_2, sigma_c |
| matériau facultatif | rho, alpha ; Drucker-Prager : beta, delta, H, friction_ult, beta_ult, k_ult |
| état, écrit après la contrainte | la 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 |
| masse | exige rho |
| géométrique | sous la contrainte sigma_* |
| tangente | par 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érateurs | pyrucast.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") |
| tag | mechanical |
| type | domaine avec comportement (label() : "Plasticity") |
| éléments | 2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 |
| cinématiques | plane_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.
| loi | vitesse | ce 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_blackburn | primaire saturant et secondaire, en sinh | les deux stades |
viscoplasticity_chaboche | \(\dot p = \langle (J(\sigma - X) - R - k)/K\rangle^n\) | viscoplasticité cyclique |
viscoplasticity_lemaitre_chaboche | la 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_strain | axisymmetric | full_3d | |
|---|---|---|---|
| primales | u_x, u_y | u_x, u_y | u_x, u_y, u_z |
| duales | f_x, f_y | f_x, f_y | f_x, f_y, f_z |
| entrée du comportement | eps_xx, eps_yy, eps_xy | eps_xx, eps_yy, eps_zz, eps_xy | eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy |
| contrainte | sigma_xx, sigma_yy, sigma_xy | sigma_xx, sigma_yy, sigma_zz, sigma_xy | sigma_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 facultatif | rho, alpha |
| état commun | la 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 |
| loi | matériau requis | état propre, après l’état commun |
|---|---|---|
creep_norton | E, nu, K, n | aucun |
creep_lemaitre | E, nu, K, N, M | aucun |
creep_blackburn | E, nu, A_1, alpha_1, r_1, B_s, beta_s | p_prim |
viscoplasticity_chaboche | E, nu, k, K, n, C_1, gamma_1, b, Q | X_xx, X_yy, X_zz, X_yz, X_xz, X_xy, R |
viscoplasticity_lemaitre_chaboche | E, nu, k, K, n, C_1, gamma_1, b, Q, S, s, D_c | X_xx, X_yy, X_zz, X_yz, X_xz, X_xy, R, damage |
Ce qu’elles produisent
| raideur | élastique |
| masse | exige rho |
| géométrique | sous la contrainte sigma_* |
| tangente | par 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érateur | pyrucast.model.mazars(fespace, kinematics, strain_measure="linear") |
| tag | mechanical |
| type | domaine avec comportement (label() : "Damage") |
| éléments | 2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 |
| cinématiques | plane_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_strain | axisymmetric | full_3d | |
|---|---|---|---|
| primales | u_x, u_y | u_x, u_y | u_x, u_y, u_z |
| duales | f_x, f_y | f_x, f_y | f_x, f_y, f_z |
| entrée du comportement | eps_xx, eps_yy, eps_xy | eps_xx, eps_yy, eps_zz, eps_xy | eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy |
| contrainte | sigma_xx, sigma_yy, sigma_xy | sigma_xx, sigma_yy, sigma_zz, sigma_xy | sigma_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 requis | E, nu, eps_d0, A_t, B_t, A_c, B_c |
| matériau facultatif | rho, alpha |
| état, écrit après la contrainte | damage, 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 |
| masse | exige rho |
| géométrique | sous la contrainte sigma_* |
| tangente | aucune : 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érateurs | pyrucast.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") |
| tag | mechanical |
| type | domaine avec comportement (label() : "Damage" ; "Plasticity" pour Gurson) |
| éléments | 2-D, en plan ou en axisymétrie : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 |
| cinématiques | plane_stress, plane_strain, axisymmetric, full_3d |
| loi | variables d’endommagement | ce 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 |
gurson | la 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_strain | axisymmetric | full_3d | |
|---|---|---|---|
| primales | u_x, u_y | u_x, u_y | u_x, u_y, u_z |
| duales | f_x, f_y | f_x, f_y | f_x, f_y, f_z |
| entrée du comportement | eps_xx, eps_yy, eps_xy | eps_xx, eps_yy, eps_zz, eps_xy | eps_xx, eps_yy, eps_zz, eps_yz, eps_xz, eps_xy |
| contrainte | sigma_xx, sigma_yy, sigma_xy | sigma_xx, sigma_yy, sigma_zz, sigma_xy | sigma_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.
| loi | matériau requis | état, écrit après la contrainte | état relu (prev) |
|---|---|---|---|
damage_tc | E, nu, f_t, f_c, A_t, A_c | damage, r_plus, r_minus, d_plus, d_minus | r_plus, r_minus, d_plus, d_minus |
damage_sic_sic | E, 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-D | damage, kappa_1, kappa_2, kappa_3, d_1, d_2, d_3 | kappa_1, kappa_2, kappa_3, d_1, d_2, d_3 |
gurson | E, nu, sigma_y, q_1, q_2, q_3, f_0, f_c, f_f | la 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 porosity | toute la sortie du pas précédent |
Ce qu’elles produisent
| raideur | élastique, non endommagée |
| masse | exige rho |
| géométrique | sous la contrainte sigma_* |
| tangente | aucune 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érateur | pyrucast.model.bernoulli(fespace) |
| tag | mechanical |
| type | domaine avec comportement (label() : "Bernoulli") |
| éléments | SEG2, sur un espace FiniteElementSpace(maillage, interpolation="HERMITE3") |
La configuration se lit sur la dimension des Coords :
Coords | inconnues par nœud | ce qui s’ajoute à la flexion |
|---|---|---|
| 1-D | w, theta | rien : flexion pure |
| 2-D | u_x, u_y, r_z | l’effort axial, et la rotation vers les axes globaux : un portique plan |
| 3-D | u_x, u_y, u_z, r_x, r_y, r_z | l’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-D | 2-D | 3-D | |
|---|---|---|---|
| primales | w, theta | u_x, u_y, r_z | u_x, u_y, u_z, r_x, r_y, r_z |
| duales | f_w, m_theta | f_x, f_y, m_z | f_x, f_y, f_z, m_x, m_y, m_z |
| matériau requis | E, I | E, A, I | E, A, I_y, I_z, J, G |
| matériau facultatif | A, rho | rho | rho |
| entrée du comportement | kappa | eps, kappa | eps, kappa_y, kappa_z, torsion |
| sortie du comportement | M | N, M | N, M_y, M_z, T |
| état | aucun | aucun | aucun |
L’entrée vient de pyrucast.element_field.beam_deformation, ou de strain.
Ce qu’elle produit
| raideur | flexion, et axial et torsion selon la configuration |
| masse | exige rho (et A en 1-D) ; le bloc de Timoshenko pris à \(\Phi = 0\) |
| géométrique | sous l’effort N ; aucune en 1-D, sans effort axial |
| tangente | = raideur |
| forces internes | le 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érateur | pyrucast.model.timoshenko(fespace) |
| tag | mechanical |
| type | domaine avec comportement (label() : "Timoshenko") |
| éléments | SEG2, 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-D | 2-D | 3-D | |
|---|---|---|---|
| primales | w, theta | u_x, u_y, r_z | u_x, u_y, u_z, r_x, r_y, r_z |
| duales | f_w, m_theta | f_x, f_y, m_z | f_x, f_y, f_z, m_x, m_y, m_z |
| matériau requis | E, I, G, A_s | E, A, I, G, A_s | E, A, I_y, I_z, J, G, A_sy, A_sz |
| matériau facultatif | A, rho | rho | rho |
| entrée du comportement | kappa, gamma | eps, kappa, gamma | eps, kappa_y, kappa_z, torsion, gamma_y, gamma_z |
| sortie du comportement | M, V, phi | N, M, V, phi | N, M_y, M_z, T, V_y, V_z, phi_y, phi_z |
| état | aucun | aucun | aucun |
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
| raideur | l’élément exact |
| masse | exige rho (et A en 1-D) |
| géométrique | sous l’effort N ; aucune en 1-D |
| tangente | = raideur |
| forces internes | le 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érateur | pyrucast.model.shell(fespace, formulation) |
| tag | mechanical |
| type | domaine avec comportement (label() : "Shell") |
| éléments | TRI3, 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
thick | kirchhoff | |
|---|---|---|
| primales | u_x, u_y, u_z, r_x, r_y, r_z | u_x, u_y, u_z, r_x, r_y, r_z |
| duales | f_x, f_y, f_z, m_x, m_y, m_z | f_x, f_y, f_z, m_x, m_y, m_z |
| matériau requis | E, nu, h | E, nu, h |
| matériau facultatif | rho, k_s | rho |
| entrée du comportement | eps_xx, eps_yy, eps_xy, kappa_xx, kappa_yy, kappa_xy, drill, gamma_xz, gamma_yz | eps_xx, eps_yy, eps_xy, kappa_xx, kappa_yy, kappa_xy, drill |
| sortie du comportement | N_xx, N_yy, N_xy, M_xx, M_yy, M_xy, M_drill, Q_xz, Q_yz | N_xx, N_yy, N_xy, M_xx, M_yy, M_xy, M_drill |
| état | aucun | aucun |
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
| raideur | membrane, flexion, cisaillement (thick) et vrillage |
| masse | — |
| géométrique | — |
| tangente | = raideur |
| forces internes | le 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_measure | la loi lit | elle écrit | domaine |
|---|---|---|---|
"linear" (défaut) | eps_* : ε = sym ∇u | sigma_* : Cauchy | petites déformations, petites rotations |
"green_lagrange" | E_* : E = ½(FᵀF − I) | S_* : Piola–Kirchhoff 2 | petites déformations, grandes rotations |
"hencky" | H_* : ½ ln C | T_* : sa duale | grandes 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 :
target | contrainte | à partir de S | duale de |
|---|---|---|---|
"cauchy" (sigma) | sigma_*, vraie contrainte | J⁻¹ F S Fᵀ | — (eulérienne) |
"kirchhoff" (tau) | tau_* | F S Fᵀ | — (eulérienne) |
"pk1" (P) | P_*, non symétrique | F S | F |
"pk2" (S) | S_* | S | E (Green–Lagrange) |
"hencky" (T) | T_* | S̃_ab / θ_ab | H = ½ 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 dvsur 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 :
target | composantes | valeur |
|---|---|---|
"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_n | inchangé |
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 Iexacte ; - un cube parfaitement plastique en Hencky, étiré à
λ = 1,5, tireA₀ σ_y / λ; - une bande étirée à
λconduit comme la bande plus longue qu’elle est, fluxk Δ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), avecG_c = k μ H₀ / (λ L₀)etG_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érenceC = ρ 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 (crochetautomatic_measure) ; - une corde tendue, chargée en son milieu, prend la flèche de l’équilibre
exact
2 S_n w / L = P; sansK_σ, 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, etJ = 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
| tag | constraint, et le tag de la physique dont elles figent les inconnues ; constraint seul pour une relation qui lie deux physiques |
| type | contrainte : ni sous-espace (fespace() rend None), ni matériau, ni comportement |
| primale | lambda, le multiplicateur, un par relation |
| duale | imposed, la ligne de la relation, où s’écrit son second membre |
| raideur | les blocs C et Cᵀ ; aucune masse ni raideur géométrique |
| tangente | = raideur |
| forces internes | Cᵀλ 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
Cporte 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 donneCᵀλ = 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é :
- statut initial : toutes les inégalités actives (ou le statut convergé précédent quand le cache est chaud — warm start) ;
- 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) ; - 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 ; - 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
NodeFieldneuf sur tous les nœuds-multiplicateurs (les relations non citées valent0), à 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 terme1·u = u_d. - Multi-points (MPC) — impose une relation linéaire
à N termes
Σₖ aₖ·u(nœudₖ, varₖ) = gentre 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 poidsNᵢ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, à coefficientsn·Nᵢcalculés par projection à la construction (petits glissements, sans frottement). Résolu parsolve, 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érateur | pyrucast.model.dirichlet(imposed_mesh, target, variable, sense="=") |
| tag | constraint et celui de la cible |
| type | contrainte (label() : "Dirichlet") |
| support | un 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
| primale | lambda, le multiplicateur |
| duale | imposed, la ligne de la relation |
| ce qu’elle contraint | variable, une inconnue de la cible, dont la duale reçoit la réaction |
| matériau, comportement | aucun |
Ce qu’elle produit
| raideur | les 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 externes | aucune ; 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érateur | pyrucast.model.mpc(terms, target, sense="=") |
| tag | constraint et celui de la cible ; constraint seul si la relation lie deux physiques |
| type | contrainte (label() : "Mpc") |
| support | un 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
| primale | lambda |
| duale | imposed |
| ce qu’elle contraint | les variables des termes, inconnues de la cible |
| matériau, comportement | aucun |
Ce qu’elle produit
| raideur | une 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 externes | aucune |
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érateur | pyrucast.model.embedded(immersed, host, target, variables, tol=1e-6) |
| tag | constraint et celui de la cible |
| type | contrainte (label() : "Embedded") |
| support | les 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
| primale | lambda, la force de liaison |
| duale | imposed |
| ce qu’elle contraint | les variables, au nœud immergé et aux nœuds hôtes |
| matériau, comportement | aucun |
Ce qu’elle produit
| raideur | une 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 externes | aucune |
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érateur | pyrucast.model.contact(slave, master, target, variables) |
| tag | constraint et celui de la cible |
| type | contrainte (label() : "Contact") |
| support | les 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,QUA8ouQUA9est 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 platsTRI3.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
TRI3etQUA4sont 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
| primale | lambda, le multiplicateur de contact (la réaction est \(-C^{\mathsf T}\lambda\)) |
| duale | imposed |
| ce qu’il contraint | les déplacements variables, aux nœuds esclaves et maîtres |
| matériau, comportement | aucun |
Ce qu’il produit
| raideur | une 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ément | Nœuds | Dimension | Élément de référence | "GAUSS" : points, degré exact | |
|---|---|---|---|---|---|
| SEG2 | 2 | 1 | \( \xi\in[-1,1] \) | Gauss 2, degré 3 | |
| TRI3 | 3 | 2 | triangle \( \xi+\eta\le 1 \) | Hammer 3, degré 2 | |
| QUA4 | 4 | 2 | \( [-1,1]^2 \) | 2×2, degré 3 par direction | |
| TET4 | 4 | 3 | tétraèdre \( \xi+\eta+\zeta\le 1 \) | Hammer 4, degré 2 | |
| PYRA5 | 5 | 3 | pyramide à base carrée | conique 8, degré 3 | |
| PENTA6 | 6 | 3 | prisme (triangle × \( \zeta \)) | 3 × 2 = 6, degré 2 dans le triangle, 3 en \( \zeta \) | |
| HEX8 | 8 | 3 | \( [-1,1]^3 \) | 2×2×2, degré 3 par direction |
Lagrange-2
| Élément | Nœuds | Dimension | Parent | Type | "GAUSS" : points, degré exact | |
|---|---|---|---|---|---|---|
| SEG3 | 3 | 1 | SEG2 | complet | Gauss 3, degré 5 | |
| TRI6 | 6 | 2 | TRI3 | complet | Dunavant 6, degré 4 | |
| QUA8 | 8 | 2 | QUA4 | sérendipité | 3×3, degré 5 par direction | |
| QUA9 | 9 | 2 | QUA4 | complet | 3×3, degré 5 par direction | |
| TET10 | 10 | 3 | TET4 | complet | Keast 11, degré 4 | |
| PENTA15 | 15 | 3 | PENTA6 | sérendipité | 6 × 3 = 18, degré 4 dans le triangle, 5 en \( \zeta \) | |
| HEX20 | 20 | 3 | HEX8 | sérendipité | 3×3×3, degré 5 par direction | |
| HEX27 | 27 | 3 | HEX8 | complet | 3×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éments | inconnues par nœud |
|---|---|---|
| conduction, diffusion | SEG2, 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, rayonnement | le bord d’une conduction : SEG2, SEG3 en 2-D ; TRI3, QUA4, TRI6, QUA8, QUA9 en 3-D | T |
| élasticité et ses lois | 2-D : TRI3, QUA4, TRI6, QUA8, QUA9 ; 3-D : TET4, PYRA5, PENTA6, HEX8, TET10, PENTA15, HEX20, HEX27 | u_x, u_y ; et u_z en 3-D |
| barre | SEG2 | un déplacement par dimension |
| poutres | SEG2 | w, theta en 1-D ; u_x, u_y, r_z en plan ; six dans l’espace |
| coque | TRI3, QUA4, dans l’espace | u_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ément | règle | familles | degré |
|---|---|---|---|
TRI3 | Hammer | 3 milieux de côté | 2 |
TET4 | Hammer | 4 points, un vers chaque sommet | 2 |
TRI6 | Dunavant | deux familles de 3 | 4 |
TET10 | Keast | le centre, une famille de 4, une de 6 | 4 |
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œuds | 2, aux extrémités |
| interpolation | "LAGRANGE1" : linéaire ; aussi "HERMITE3" et "MODEL_EMBEDDED" pour les poutres |
| dimension | 1 ; dans des Coords 1-D, 2-D ou 3-D |
| bord | ses deux extrémités |
| quadrature | "GAUSS" : 2 points, degré 3 ; "REDUCED" : 1 point |
| version quadratique | SEG3 |
| obtenu par | line, 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 2 |
| échanges, rayonnement, sur le bord d’une surface | T | 2 |
| barre | u_x en 1-D ; u_x, u_y en plan ; u_x, u_y, u_z dans l’espace | 2, 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’espace | 4, 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œuds | 3, aux sommets |
| interpolation | "LAGRANGE1" : affine |
| dimension | 2 ; dans des Coords 2-D ou 3-D |
| bord | 3 SEG2 |
| quadrature | "GAUSS" : 3 points, degré 2 ; "REDUCED" : 1 point |
| version quadratique | TRI6 |
| obtenu par | triangulate_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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 3 |
| élasticité plane ou axisymétrique | u_x, u_y | 6 |
| coque, dans l’espace | u_x, u_y, u_z, r_x, r_y, r_z | 18 |
| échanges, rayonnement, sur la face d’un volume | T | 3 |
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œuds | 4, aux sommets |
| interpolation | "LAGRANGE1" : bilinéaire |
| dimension | 2 ; dans des Coords 2-D ou 3-D |
| bord | 4 SEG2 |
| quadrature | "GAUSS" : 2×2 points, degré 3 par direction ; "REDUCED" : 1 point |
| versions quadratiques | QUA8 (sérendipité), QUA9 (complet) |
| obtenu par | sweep, 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 4 |
| élasticité plane ou axisymétrique | u_x, u_y | 8 |
| coque, dans l’espace | u_x, u_y, u_z, r_x, r_y, r_z | 24 |
| échanges, rayonnement, sur la face d’un volume | T | 4 |
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œuds | 4, aux sommets |
| interpolation | "LAGRANGE1" : affine |
| dimension | 3 ; dans des Coords 3-D |
| bord | 4 TRI3 |
| quadrature | "GAUSS" : 4 points, degré 2 ; "REDUCED" : 1 point |
| version quadratique | TET10 |
| obtenu par | triangulate_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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 4 |
| élasticité 3-D | u_x, u_y, u_z | 12 |
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œuds | 5 : les quatre coins de la base, le sommet |
| interpolation | "LAGRANGE1" : bilinéaire sur chaque section, rationnelle |
| dimension | 3 ; dans des Coords 3-D |
| bord | 1 QUA4, 4 TRI3 |
| quadrature | "GAUSS" : 8 points, degré 3 ; "REDUCED" : 1 point |
| version quadratique | aucune : to_quadratic refuse une pyramide |
| obtenu par | pave_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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 5 |
| élasticité 3-D | u_x, u_y, u_z | 15 |
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œuds | 6 : le triangle du bas, puis celui du haut |
| interpolation | "LAGRANGE1" : affine dans le triangle, linéaire en hauteur |
| dimension | 3 ; dans des Coords 3-D |
| bord | 2 TRI3, 3 QUA4 |
| quadrature | "GAUSS" : 3 × 2 = 6 points ; "REDUCED" : 1 point |
| version quadratique | PENTA15 |
| obtenu par | extrude, 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 6 |
| élasticité 3-D | u_x, u_y, u_z | 18 |
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œuds | 8, aux sommets |
| interpolation | "LAGRANGE1" : trilinéaire |
| dimension | 3 ; dans des Coords 3-D |
| bord | 6 QUA4 |
| quadrature | "GAUSS" : 2×2×2 points, degré 3 par direction ; "REDUCED" : 1 point |
| versions quadratiques | HEX20 (sérendipité), HEX27 (complet) |
| obtenu par | extrude, 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 8 |
| élasticité 3-D | u_x, u_y, u_z | 24 |
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œuds | 3 : les extrémités, puis le milieu |
| interpolation | "LAGRANGE2" : quadratique |
| dimension | 1 ; dans des Coords 1-D, 2-D ou 3-D |
| bord | ses deux extrémités |
| quadrature | "GAUSS" : 3 points, degré 5 ; "REDUCED" : 1 point |
| parent linéaire | SEG2 |
| obtenu par | line, 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 3 |
| échanges, rayonnement, sur le bord d’une surface quadratique | T | 3 |
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œuds | 6 : les sommets, puis les milieux des côtés |
| interpolation | "LAGRANGE2" : quadratique complète |
| dimension | 2 ; dans des Coords 2-D ou 3-D |
| bord | 3 SEG3 |
| quadrature | "GAUSS" : 6 points, degré 4 ; "REDUCED" : 1 point |
| parent linéaire | TRI3 |
| obtenu par | sweep, 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 6 |
| élasticité plane ou axisymétrique | u_x, u_y | 12 |
| échanges, rayonnement, sur la face d’un volume quadratique | T | 6 |
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.
| famille | point 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œuds | 8 : les sommets, puis les milieux des côtés |
| interpolation | "LAGRANGE2" : sérendipité |
| dimension | 2 ; dans des Coords 2-D ou 3-D |
| bord | 4 SEG3 |
| quadrature | "GAUSS" : 3×3 points, degré 5 par direction ; "REDUCED" : 1 point |
| parent linéaire | QUA4 |
| obtenu par | sweep, 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 8 |
| élasticité plane ou axisymétrique | u_x, u_y | 16 |
| échanges, rayonnement, sur la face d’un volume quadratique | T | 8 |
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œuds | 9 : les sommets, les milieux des côtés, le centre |
| interpolation | "LAGRANGE2" : complète |
| dimension | 2 ; dans des Coords 2-D ou 3-D |
| bord | 4 SEG3 |
| quadrature | "GAUSS" : 3×3 points, degré 5 par direction ; "REDUCED" : 1 point |
| parent linéaire | QUA4 |
| obtenu par | sweep, 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 9 |
| élasticité plane ou axisymétrique | u_x, u_y | 18 |
| échanges, rayonnement, sur la face d’un volume quadratique | T | 9 |
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œuds | 10 : les sommets, puis les milieux des arêtes |
| interpolation | "LAGRANGE2" : quadratique complète |
| dimension | 3 ; dans des Coords 3-D |
| bord | 4 TRI6 |
| quadrature | "GAUSS" : 11 points, degré 4 ; "REDUCED" : 1 point |
| parent linéaire | TET4 |
| obtenu par | to_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œud | arê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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 10 |
| élasticité 3-D | u_x, u_y, u_z | 30 |
Quadrature
"GAUSS" : règle de Keast à 11 points, exacte au degré 4 :
| famille | points | poids |
|---|---|---|
| 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 milieux | les 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œuds | 15 : les sommets, puis les milieux des arêtes |
| interpolation | "LAGRANGE2" : sérendipité |
| dimension | 3 ; dans des Coords 3-D |
| bord | 2 TRI6, 3 QUA8 |
| quadrature | "GAUSS" : 6 × 3 = 18 points ; "REDUCED" : 1 point |
| parent linéaire | PENTA6 |
| obtenu par | to_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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 15 |
| élasticité 3-D | u_x, u_y, u_z | 45 |
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œuds | 20 : les sommets, puis les milieux des arêtes |
| interpolation | "LAGRANGE2" : sérendipité |
| dimension | 3 ; dans des Coords 3-D |
| bord | 6 QUA8 |
| quadrature | "GAUSS" : 3×3×3 points, degré 5 par direction ; "REDUCED" : 1 point |
| parent linéaire | HEX8 |
| obtenu par | to_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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 20 |
| élasticité 3-D | u_x, u_y, u_z | 60 |
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œuds | 27 : sommets, milieux des arêtes, centres des faces, centre |
| interpolation | "LAGRANGE2" : complète |
| dimension | 3 ; dans des Coords 3-D |
| bord | 6 QUA9 |
| quadrature | "GAUSS" : 3×3×3 points, degré 5 par direction ; "REDUCED" : 1 point |
| parent linéaire | HEX8 |
| obtenu par | lecture : 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é
| physique | inconnues par nœud | par maille |
|---|---|---|
| conduction, diffusion | T | 27 |
| élasticité 3-D | u_x, u_y, u_z | 81 |
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 Rust | Chapitre | Contenu |
|---|---|---|
ops::mesh | Maillage | line, 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::model | Modèle | les 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_field | Construction | champs matériau (material_field…) |
ops::coords | Champs | set, 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 | Évolution | along (un champ relevé le long d’une ligne de points, une courbe par composante) |
ops::measure | Champs | integral / integral_element (∫ f dΩ), xtx / xty (produits scalaires globaux) |
ops::geom | Géométrie | locate_points (mapping inverse, baignage), project_points (projection sur surface, contact) — internes, pas exposées à Python |
ops::node_field, ops::element_field, ops::field | Champs | positions, 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::matrix | Assemblage | stiffness, 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::behavior | Comportement | initial_state (l’état de repos, l’état de A du premier pas), integrate_behavior (le COMP) |
ops::solver | Solveur | solve : multiplicateurs (LU creux), élimination (relations="eliminate"), ensemble actif dès qu’une relation est unilatérale |
ops::export | Visualisation | export_vtk (maillage / champ / évolution → VTK ASCII ou binaire pour ParaView), to_arrays, to_gmsh, to_medcoupling |
archive | Sauvegarde et relecture | save / load (graphe d’objets, partage préservé) — au niveau racine, hors ops |
src/viz | Visualisation | tracé 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
| Python | Rô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"— chaqueQUA4est coupé en deuxTRI3le long de la diagonale(0, 2)(pas de nœud créé) ;"QUA8"— promotion quadratique viato_quadratic(nœuds de milieu d’arête) ;"QUA9"— commeQUA8, puis un nœud central neuf est ajouté par cellule (moyenne des 4 coins) —to_quadraticne produit que leQUA8sérendipité, sans nœud central ;"TRI6"— coupe enTRI3puis 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.
DALLaccepte 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).transfinitese 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êmeCoords— 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=Falsecopie 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.
translatedécale chaque nœud devector(dont la longueur doit valoir la dimension du maillage).rotatetourne deangleradians autour decenter. En 2D,centerest un point etaxisest ignoré ; en 3D, la rotation se fait autour de la droite passant parcenterdirigée paraxis(formule de Rodrigues, main droite), etaxisest obligatoire (il n’a pas besoin d’être normé).symmetry_point(mesh, center)envoie chaque nœud sur2·center − x:centerest 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 paraetb: 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’estsymmetry_planequ’il faut.symmetry_plane(mesh, a, b, c)réfléchit à travers le plan passant par les trois pointsa,betc: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 retournen̂, ce à quoi la formule est insensible). 3D uniquement : en 2D, le miroir estsymmetry_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érateur | 2D | 3D |
|---|---|---|
symmetry_point | direct (demi-tour) | retourné |
symmetry_line | retourné | 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, ¢er, 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 unanglepositif) ;axisest ignoré. Seul unSEG2a 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
centerdirigée paraxis(main droite) ;axisest 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_typeest 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
- les points de bord sont insérés un à un dans une triangulation de Delaunay par Bowyer-Watson (super-triangle englobant) ;
- chaque arête de boucle absente est récupérée (retrait du corridor + ear-clipping des deux polygones adjacents) puis marquée contrainte ;
- la triangulation est légalisée (flips de Delaunay ne traversant aucune contrainte) ;
- 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 ;
- 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 ;
- 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
sizefaçonCHPO1). - 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_surface | pave_surface | |
|---|---|---|
| méthode | Delaunay contraint + Ruppert | front avançant |
| élément naturel | TRI3 | QUA4 |
QUA4 obtenu par | recombinaison de paires | construction |
| rangées alignées sur le bord | non | oui |
| quadrangles seuls | impossible | pré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.
-
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.
-
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.
-
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.
-
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.
-
Déblocage. Une boucle qui n’avance plus est coupée en deux par une corde, et les deux moitiés reprennent le pavage.
-
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.
-
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.
-
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_quadssur 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.
relax | mailles | pire maille | aire médiane |
|---|---|---|---|
"free" (défaut) | 592 | 0,619 | 0,62 |
"along" | 400 | 1,000 | 1,00 |
"none" | 400 | 1,000 | 1,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 × 20 | 592 mailles, pire 0,619 | 400, 1,000 | 400, 1,000 |
| bande étroite 40 × 3 | 123, 0,702 | 120, 1,000 | 123, 0,684 |
| L à cotes quelconques | 111, 0,680 | 66, 0,780 | 66, 0,774 |
| créneau, base coupée | 816, 0,118 | 586, 0,426 | 577, 0,546 |
| cercle R = 1 | 547, 0,081 | 610, 0,281 | 620, 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.consolidateaprès avoir uni les côtés. - Orientation. Un trou doit être CW.
pyrucast.mesh.invertretourne un cercle construit en CCW. - La taille du contour compte. Le front part de la discrétisation du bord
et converge vers
sizeen quelques rangées. Un contour beaucoup plus grossier quesizedonne 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 :
sizeest 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’estgrid_surfacequ’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_surfacequ’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
- 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. - 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.
- 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.
- 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.
banden retire davantage. - 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.
- 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 :
| angle | avant la détection | après |
|---|---|---|
| 0° | 450 mailles, 0 triangle, qualité 1,000 | identique |
| 5° | 496 (28 tri), 64 % de mailles parfaites | 450, 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_surface | grid_surface | |
|---|---|---|
| mailles | 6 428 QUA4 + 26 TRI3 | 4 032 QUA4, 0 TRI3 |
| qualité — min | 0,229 | 1,000 |
| qualité — médiane | 0,854 | 1,000 |
| mailles sous 0,7 | 3,13 % | 0 |
| aire couverte | exacte | exacte |
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
-
bandvaut 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. -
relaxgouverne 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 :
| forme | grid_surface | grid_surface2 |
|---|---|---|
| rectangle, et toute forme pile sur la grille | 0,999 | 0,999 |
| plaque à marche hors grille | 0,405 | 0,963 |
| L à cotes quelconques | 0,437 | 0,979 |
| L dont les côtés découpent 5+6 contre 4+7 | 0,421 | 0,963 |
| L dont les côtés diffèrent d’un nœud | 0,351 | 0,606 |
| profil crénelé, base coupée sous chaque barre | 0,382 | 0,916 |
| profil crénelé, base d’un seul tenant | 0,323 | 0,651 |
| maison à toit à deux pentes | 0,420 | 0,548, 1 triangle contre 11 |
| carré à un angle arrondi | 0,244 | 0,400 |
| cercle R = 1 | 0,288, p5 0,796 | 0,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é min | p1 | p5 | |
|---|---|---|---|
| brut | 0,366 | 0,471 | 0,800 |
| laplacien | 0,806 | 0,807 | 0,863 |
| angulaire | 0,781 | 0,807 | 0,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.
- 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.
- 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’étoile | avant | après | mailles |
|---|---|---|---|---|
| 3, 0 | hexagone | 3 quadrangles | 2 quadrangles | 3 → 2 |
| 2, 1 | pentagone | 2 quadrangles, 1 triangle | 1 de chaque | 3 → 2 |
| 1, 2 | quadrangle | 1 quadrangle, 2 triangles | 1 quadrangle | 3 → 1 |
| 0, 3 | triangle | 3 triangles | 1 triangle | 3 → 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) \) | bord | avant | après | mailles |
|---|---|---|---|---|
| 3, 3 | hexagone | 4 quadrangles | 2 quadrangles | 4 → 2 |
| 3, 4 | heptagone | 4 quadrangles, 1 triangle | 2 quadrangles, 1 triangle | 5 → 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éé :
-
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.
-
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.
-
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 :
| mailles | triangles | qualité min | p1 | p5 | |
|---|---|---|---|---|---|
| brut | 1 236 | 8 | 0,366 | 0,471 | 0,800 |
| après la chaîne | 1 216 | 0 | 0,781 | 0,803 | 0,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.invertla 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) :
| cas | mailles | temps | débit | jacobien médian | inversées |
|---|---|---|---|---|---|
| cube 6³, 1 couche | 4 054 | 0,22 s | 18 700 /s | 0,374 | 0 |
| cube 8³, 1 couche | 7 422 | 0,38 s | 19 500 /s | 0,345 | 0 |
| cube 12³, 1 couche | 16 454 | 0,80 s | 20 600 /s | 0,418 | 0 |
| cube 16³, 1 couche | 35 015 | 1,72 s | 20 300 /s | 0,431 | 0 |
Le coût est linéaire, autour de 20 000 mailles/s et 50 µs par maille. Par type, sur le cube 16³ :
| type | minimum | médiane |
|---|---|---|
HEX8 | 0,577 | 0,987 |
PYRA5 | 0,181 | 0,319 |
TET4 | 0,040 | 0,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 :
size | mailles | part sous 10° | part sous 1° | \( \eta \) de la plus plate |
|---|---|---|---|---|
| 0,01 | 12 850 | 0,36 % → 0,00 % | 0 → 0 | \( 9{,}3\cdot10^{-2} \) → \( 3{,}5\cdot10^{-1} \) |
| 0,005 | 97 574 | 0,89 % → 0,005 % | 0,002 % → 0 | \( 1{,}6\cdot10^{-2} \) → \( 7{,}9\cdot10^{-2} \) |
| 0,0033 | 340 329 | 1,99 % → 0,10 % | 0,017 % → 0,0003 % | \( 8{,}3\cdot10^{-3} \) → \( 1{,}9\cdot10^{-2} \) |
| 0,0025 | 378 190 | 0,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 :
size | facettes | tétraèdres | temps |
|---|---|---|---|
| 0,01 | 3 304 | 12 850 | 2,3 s |
| 0,006 | 8 096 | 58 669 | 6,6 s |
| 0,004 | 17 824 | 195 919 | 19 s |
| 0,003 | 30 616 | 462 447 | 51 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 detol;points_on_*— à moins detolde 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_nodesopère au sein d’une mêmeCoords(l’invariant duMeshimpose déjà uneCoordscommune à tous les sous-maillages). Deux pièces maillées dans desCoordsséparées ne se soudent donc pas : il faut d’abord les amener dans la mêmeCoords.
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
Meshpar 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
Meshpartagent laCoordsfournie : 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 votreCoords, 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
Coordspeut 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 quelMeshdudict.
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 gmsh | Type pyrucast |
|---|---|
1 | SEG2 |
2 | TRI3 |
3 | QUA4 |
4 | TET4 |
5 | HEX8 |
6 | PENTA6 |
7 | PYRA5 |
15 | POI1 |
8 | SEG3 |
9 | TRI6 |
16 | QUA8 |
10 | QUA9 |
11 | TET10 |
17 | HEX20 |
18 | PENTA15 |
12 | HEX27 |
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 gmsh | champ pyrucast |
|---|---|
NodeData | NodeField |
ElementData | ElementField à un point par maille, avec une zone sur chaque groupe dont il définit toutes les mailles |
| plusieurs pas de temps | Evolution 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 :
| maillon | copie ? | pourquoi |
|---|---|---|
| gmsh → numpy | non | les tableaux que rend l’API gmsh sont des vues sur la mémoire de gmsh, libérées au ramasse-miettes du tableau |
| numpy → pyrucast | non | lecture 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 maillage | oui, une passe | une 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 quefrom_gmshvoit tout le modèle. Le.mshrelu 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
Meshpar groupe MED — groupes de mailles de tous les niveaux, et groupes de nœuds sous forme deMeshPOI1 —, tous sur laCoordsfournie, 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 MED | champ pyrucast |
|---|---|
ON_NODES | NodeField sur les nœuds définis (profil compris) |
ON_CELLS | ElementField à un point par maille |
ON_GAUSS_PT | ElementField sur les points de Gauss de pyrucast — si la règle MED est la même, sinon une erreur explicite |
| plusieurs pas de temps | Evolution 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 (
uint64ouint64) 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=Trueses valeurs brutes et la règle de chaque type ; - chaque tableau est un
pyrucast.Array, en lecture seule, quenumpy.asarrayoumemoryviewlisent 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_arean’est pas exposée en Python : elle est utilisée en interne partriangulate_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 :
-
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 \)).
-
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_normaletin_plane_basisne sont pas exposées en Python. Elles sont utilisées en interne partriangulate_surfacepour 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})\) :
-
On choisit l’axe canonique \(\vec{e}\) le moins aligné avec \(\hat{n}\) (composante absolue minimale).
-
On orthogonalise par Gram-Schmidt :
\[ \vec{u}' = \vec{e} - (\vec{e} \cdot \hat{n})\, \hat{n}, \qquad \vec{u} = \frac{\vec{u}'}{\|\vec{u}'\|} \]
-
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_surfacese charge de la conversion vers lesNodeId.
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 :
- On identifie l’ensemble bad des triangles dont le cercle circonscrit contient \(p\) (déterminant ci-dessus > 0).
- Leur union forme un polygone étoilé autour de \(p\) (théorème de Bowyer 1981 et Watson 1981).
- On retire ces triangles ; le bord de la cavité est un polygone simple.
- 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 :
- 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.
- 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.
- 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érieur | Statut |
|---|---|
| 0 | extérieur (hors du contour englobant) |
| 1 | intérieur du domaine maillé |
| 2 | dans un trou |
| 3 | îlot dans un trou |
| … | alterne |
Le flood-fill par parité :
- Tout triangle adjacent au super-triangle est étiqueté « extérieur ».
- 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.
- À 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_surfacene le fait pas : le contour d’entrée doit être conservé à l’identique (mêmesNodeId, 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).
| Python | Effet |
|---|---|
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
ElementFieldordinaire : 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 surx − Σ Nᵢ(ξ)·Xᵢ, test d’appartenance au domaine de référence), d’où les poidsNᵢ(ξ)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 dimensionsdim−1:SEG2/SEG3en 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), poidsNᵢ(ξ), 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
| Python | Effet |
|---|---|
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
| Python | Effet |
|---|---|
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 :
| Argument | Test | Opérateur |
|---|---|---|
ge | v ≥ ge | >= |
gt | v > gt | > |
le | v ≤ le | <= |
lt | v < 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é).
| Python | Effet |
|---|---|
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 unElementField/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=Noneteste toutes les composantes de chaque zone. Une listecomponentsne 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.
| Python | Effet |
|---|---|
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=Noneteste toutes les composantes. Une listecomponentsne 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 avecpython examples/field_mask.pyaprèsmaturin 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.
| Python | Effet |
|---|---|
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\) :
measure | formule | composantes |
|---|---|---|
"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 volumes | J 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ématique | composantes lues | prises nulles |
|---|---|---|
full_3d | les six sigma_* | — |
plane_stress | sigma_xx, sigma_yy, sigma_xy | zz, yz, xz |
plane_strain | sigma_xx, sigma_yy, sigma_zz, sigma_xy | yz, xz |
axisymmetric | sigma_xx, sigma_yy, sigma_zz (orthoradiale), sigma_xy | yz, 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_integralintègre chaque composante maille par maille, avec la quadrature du champ d’entrée :v_e = Σ_g f(g)·|J|_g·w_g(le2πrinclus en axisymétrique). Le résultat vit surtarget, 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_nodesrassemble 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 :
Coords | DDL lus | composantes produites |
|---|---|---|
| 1-D | w, theta | kappa, gamma |
| 2-D | u_x, u_y, r_z | eps, kappa, gamma |
| 3-D | six | eps, 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.
| formulation | composantes produites |
|---|---|
thick | eps_xx, eps_yy, eps_xy, kappa_xx, kappa_yy, kappa_xy, drill, gamma_xz, gamma_yz |
kirchhoff | les 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èle | producteur | zone rendue |
|---|---|---|
| élasticité, plasticité, endommagement, barre | deformation | eps_*, ou la mesure de la loi (E_*, H_*) |
| conduction, Fick | gradient | grad_T_*, grad_c_<espèce>_* |
| rayonnement | interp_to_gauss | T aux points |
| poutres Bernoulli, Timoshenko | beam_deformation | eps, kappa*, gamma*, torsion |
| coque | shell_deformation | eps_*, 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.
| Python | Effet |
|---|---|
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 :
| Python | Cast3M | Réduit | Résultat |
|---|---|---|---|
xty(x, y) | XTY | tout (nœuds/points × composantes) | un float |
psca(x, y) | PSCA | les composantes seules, nœud par nœud | un 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) → unfloat;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Ω—fespaceest requis ; - sur un
ElementField: les valeurs (déjà aux points de Gauss) sont intégrées directement —fespaceest 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éduction | Réduit | Résultat |
|---|---|---|
field.min(comp) / field.max(comp) | une composante | un float (exact) |
field.min() / field.max() | toutes les composantes | un 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.
- l’entrée de déformation
ε(B)est produite parstrain(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 ; prevest l’état de A : la sortie du pas précédent, soit la contrainteσ(A), les variables internesVAR(A)et la déformationε(A). Au premier pas, c’est l’état de reposinitial_state(model, materials), A étant la configuration de référence ;dtest l’incrément de temps,Nonepour une loi indépendante du temps ; une loi de fluage ou de viscoplasticité lève une erreur sans lui ;integrate_behaviorprend ces entrées plus le matériau par zone et applique la loi de chaque physique point par point ;- 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 commeprevau 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/s1de 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 :
- obtient la factorisation de
A(cache de la matrice, cf. ci-dessous) ; - lit le
NodeFieldde chargement à chacune des lignes de la matrice (les entrées absentes valent0.0par défaut) ; - effectue la descente/remontée pour ce second membre ;
- emballe la solution dans un
NodeFieldindexé 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 savoir | où il le lit |
|---|---|
| quelles lignes sont des relations | les lignes de nom imposed, le nom commun de toutes les contraintes |
| leurs coefficients | la ligne elle-même |
| leur multiplicateur | la 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
| option | défaut | rô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_iter | 100 | borne de la boucle de statut |
tol | 1e-10 | tolérance de signe sur λ et sur le jeu |
cache | True | ré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=Falsene 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
Kphysique 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 :
- partir d’un statut d’essai (toutes actives, ou le statut convergé précédent quand le cache est chaud — un warm start) ;
- 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) ; - 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 ; - 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 × kG = I + Vᵀ Xest l’opérateur de Delassus restreint aux relations actives — dense, factorisé par une LU dense (nalgebra) à chaque itération (coûtk³/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 densek × 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 :
| DDL | assemblage | solve (Lagrange + LU) | relations="eliminate" + method="cholesky" |
|---|---|---|---|
| 31 k | 28 Mo | 1,01 Go, 10,8 s | 0,28 Go, 1,8 s |
| 135 k | 118 Mo | 10,6 Go, 330 s | 2,22 Go, 42 s |
| 363 k | 348 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.
| Variable | Rôle |
|---|---|
PYRUCAST_SPILL_DIR | Répertoire des fichiers de débordement. Absente, le débordement est inactif. |
PYRUCAST_SPILL_MIN | Taille, en octets, à partir de laquelle une allocation déborde. Défaut : 64 Mio. |
PYRUCAST_SPILL_LOG | Pré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 :
| Configuration | blocs débordés | pic anonyme | pic total | temps |
|---|---|---|---|---|
sans la feature spill | — | 6,84 Go | 6,84 Go | 163 s |
feature, sans PYRUCAST_SPILL_DIR | — | 6,84 Go | 6,85 Go | 159 s |
| seuil 8 Gio | 0 | 6,87 Go | 6,88 Go | 160 s |
| seuil 1 Gio | 2 | 1,13 Go | 6,85 Go | 441 s |
| seuil 64 Mio | 25 | 0,26 Go | 6,84 Go | 475 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 :
- Dirichlet — Poisson 1-D
-u'' = 0,u(0)=0,u(1)=1; - Conduction thermique — ligne chauffée et carré ;
- Mécanique — treillis, élasticité, poutres.
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 Cargo | Apport | Dépendances ajoutées |
|---|---|---|
| (aucune) | rien — bibliothèque de calcul pure | — |
viz | export 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
vizetviz-interactive; la sdist, sur laquellepipretombe 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 ;>1zoom,<1dé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 :
save | Effet | Compilation nécessaire |
|---|---|---|
None | ouvre une fenêtre interactive (souris : rotation au glisser, molette : zoom) | viz-interactive |
Some(path) avec extension .png | écrit un PNG | viz |
Some(path) avec extension .svg | écrit un SVG vectoriel | viz |
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, letitleest 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 parmesh.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.
cmap | Dégradé | Usage |
|---|---|---|
"viridis" (défaut) | violet → bleu → vert → jaune | usage général ; perceptuellement uniforme, lisible en niveaux de gris et pour les daltoniens |
"coolwarm" | bleu → blanc → rouge | données signées centrées sur 0 (le blanc marque le milieu de l’échelle) |
"hot" | noir → rouge → jaune → blanc | rendu thermique |
"gray" | noir → blanc | impression N&B, superposition |
"jet" | bleu → vert → rouge | ancien 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 passemesh=<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
plotlève une erreur.
Types d’éléments rendus
Tous les types d’éléments sont rendus, chacun converti en une primitive géométrique :
| Type | Primitive | Rendu |
|---|---|---|
POI1 | point | un point coloré |
SEG2 | segment | une arête |
TRI3 | face | triangle plein + contour noir |
QUA4 | face | quadrangle plein + contour noir |
TET4 | faces triangulaires | la peau du volume (facettes de bord) |
HEX8 | faces quadrangulaires | la 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 :
| Style | Rendu |
|---|---|
Surface (défaut) | la peau externe opaque ; l’intérieur est masqué |
Wireframe | toutes 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.
| Argument | Défaut | Effet |
|---|---|---|
revolve | False | True ⇒ trace le corps de révolution au lieu de la section |
revolve_angle | 360.0 | angle 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
Coords2-D est complétée en 3-D avecz = 0. - Un
NodeFielddonne un tableauSCALARSpar composante aux points (valeur nodale,0là où le champ n’est pas défini) ; unElementFielddonne 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
SCALARSdistinct ; pas de regroupement enVECTORS/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,PENTA15etHEX20aux 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 (leTRI6ré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, maisgetLocalizationOfDiscr()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 avecexport_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-interactivené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 featureviz-interactiveet 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érateur | méthode | types et options | rend |
|---|---|---|---|
triangulate_surface | Delaunay contraint + raffinement de Ruppert | "TRI3", "QUA4" ; size | des TRI3 — en QUA4, des quadrangles et quelques triangles |
pave_surface | front avançant, en rangées depuis le bord | "QUA4", "QUA8", "QUA9" ; size, prefer_quads, relax | des QUA4, quelques TRI3 |
grid_surface | cœur en grille + bande frontale au bord | "QUA4", "QUA8", "QUA9" ; size, band, prefer_quads, relax | des QUA4, quelques TRI3 |
grid_surface2 | cœur en grille, une ligne par nœud du contour, + bande frontale au bord | "QUA4", "QUA8", "QUA9" ; size, band, prefer_quads, relax | des 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éfautFalse) —Truerefuse 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éfaut0) — 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.
| forme | triangulate | pave | grid | grid2 | |
|---|---|---|---|---|---|
| rectangle 1 × 0,63 | mailles | 286 | 96 | 60 | 60 |
| pire | 0,425 | 0,389 | 0,999 | 0,999 | |
| plaque, marche sur la grille | mailles | 394 | 124 | 80 | 80 |
| pire | 0,489 | 0,669 | 1,000 | 1,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.
| forme | triangulate | pave | grid | grid2 | |
|---|---|---|---|---|---|
| plaque à marche (0,53 ; 0,61) | mailles | 366 | 130 | 86 | 80 |
| pire | 0,481 | 0,571 | 0,405 | 0,963 | |
| L à cotes quelconques | mailles | 308 | 111 | 76 | 70 |
| pire | 0,459 | 0,680 | 0,437 | 0,979 | |
| L étiré à 1,02 | mailles | 319 | 107 | 81 | 74 |
| pire | 0,477 | 0,639 | 0,307 | 0,606 | |
| L étiré à 1,10 | mailles | 336 | 112 | 80 | 74 |
| pire | 0,474 | 0,649 | 0,421 | 0,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.
| forme | triangulate | pave | grid | grid2 | |
|---|---|---|---|---|---|
| base coupée | mailles | 2 050 | 816 | 456 | 456 |
| pire | 0,412 | 0,118 | 0,860 | 0,916 | |
| base d’un seul tenant | mailles | 2 089 | 827 | 471 | 456 |
| pire | 0,419 | 0,415 | 0,287 | 0,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.
| forme | triangulate | pave | grid | grid2 | |
|---|---|---|---|---|---|
| maison | mailles | 1 723 | 611 | 447 | 450 |
| pire | 0,484 | 0,515 | 0,365 | 0,548 | |
| carré arrondi | mailles | 1 866 | 665 | 418 | 404 |
| pire | 0,474 | 0,360 | 0,340 | 0,467 | |
| cercle R = 1 | mailles | 1 496 | 547 | 312 | 530 |
| pire | 0,527 | 0,081 | 0,179 | 0,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 :
| taille | triangulate | pave | grid | grid2 |
|---|---|---|---|---|
| 0,05 | 0,424 | 0,040 | 0,366 | 0,011 |
| 0,06 | 0,526 | 0,031 | 0,291 | 0,406 |
| 0,07 | 0,424 | 0,015 | 0,137 | 0,012 |
| 0,08 | 0,436 | 0,039 | 0,191 | 0,035 |
| 0,1 | 0,527 | 0,081 | 0,179 | 0,444 |
| 0,12 | 0,497 | 0,003 | 0,362 | 0,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_surfacesi la qualité du pire élément commande,grid_surfaces’il faut des quadrangles ; jamaisgrid_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 × 20 | 592 mailles, pire 0,619 | 400, 1,000 | 400, 1,000 |
| bande étroite 40 × 3 | 123, 0,702 | 120, 1,000 | 123, 0,684 |
| L à cotes quelconques | 111, 0,680 | 66, 0,780 | 66, 0,774 |
| créneau, base coupée | 816, 0,118 | 586, 0,426 | 577, 0,546 |
| cercle R = 1 | 547, 0,081 | 610, 0,281 | 620, 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ôle | Cast3M |
|---|---|---|
model | modèle complet : physiques, contraintes, charges portées | MODELE |
times | instants calculés | TEMPS_CALCULES |
save_times | instants enregistrés (défaut : times) | TEMPS_SAUVES |
materials | champ matériau, ou Evolution de champs dans le temps | CARACTERISTIQUES |
material_laws | lois scalaires de l’état, E(T), σ_y(T, φ)… (Évolution) | VARI |
loads | charges nodales et valeurs imposées, champ ou Evolution | CHARGEMENT |
temperature | température imposée, sans modèle thermique | CHARGEMENT 'T' |
initial | displacement, 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_ref | indice 0 des tables |
t_ref | température sans dilatation | TEMPERATURE_REFERENCE |
hooks | procé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), aveca = 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=">="ouModel.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) :Δuest mis à l’échelle pour que sa déformation vailleautomatic_target, et la fraction de chargeα ← α − (r·Δu)/(R·Δu); le temps n’avance que deα. Le crochetautomatic_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 cassnap.dgibiredéfinitAUTOPILOde 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
| Cast3M | pyrucast |
|---|---|
PASAPAS | stepping.step_by_step |
PAS_DEFA | stepping.resolve_options |
PAS_MODL | stepping.describe_problem |
PAS_INIT | stepping.initial_state |
PAS_ETAT, PAS_MATE | stepping.materials_at |
PAS_RESU | stepping.store_step |
PAS_AJUSTE | stepping.adjust_time_step |
TRANSNON, TRANSLIN, DUPONT2 | stepping.heat_step, stepping.heat.heat_step_linear, stepping.heat.heat_step_dupont |
UNPAS | stepping.mechanical_step |
opérateur initial, F_ref, F_NL | stepping.mechanics.iteration_operator, reference_forces, nonlinear_predictor |
itérations, COMP, critères | stepping.mechanics.equilibrium_iterations, integrate_increment, convergence_status |
ACT3 | stepping.anderson.anderson_correction |
AUTOPILO, PILOINDI | stepping.control.automatic_control (ou le crochet automatic_measure), indirect_control |
REEV_MEC, REEV_THE, PERSO1, CHARMECA, CHARTHER, PARATHER | crochets after_mechanics, after_heat, after_step, extra_mechanical_load, extra_heat_load, heat_material_update |
GRANDS_DEPLACEMENTS, LAGRANGIEN | la mesure des lois (strain_measure) ; toujours le lagrangien total |
K_SIGMA | geometric_stiffness |
FORM (GEOM2) | stepping.configuration.Configurations |
RFCO | model.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 ;DUPONT2dégénère en θ = ¾ entre deux pas. - Rayonnement : l’opérateur est la tangente
4σεT³àT_nplutô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émalinearliné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_refquand elle n’est pas donnée. - La tangente (
tangent) est la tangente cohérente de la loi, là oùKTANdonne 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_NLen grands déplacements : une rotation finie n’est pas dans le noyau de l’opérateur, si bien queK Δxy 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
RdeF = 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 pyrucastIl 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
- Premiers pas — cette page.
- Ce que fait pyrucast — les étapes d’un calcul, les physiques disponibles, où trouver la documentation.
- Python & conventions pyrucast — le Python des scripts, les raccourcis de pyrucast, où chercher une fonction.
- Maillage — mailler la chape, en non structuré (triangulation) puis en structuré quadratique (balayages).
- 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).
- Thermique non linéaire — le rayonnement, en kelvins ; le champ parcouru dans le temps, la sauvegarde du calcul.
- 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).
- 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).
- 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 :
| étape | ce qu’on fait | dans pyrucast | vu dans |
|---|---|---|---|
| géométrie, maillage | poser des points, mailler lignes, surfaces et volumes | Coords, pyrucast.mesh | Premiers pas, Maillage |
| espace éléments finis | choisir l’interpolation sur le maillage | FiniteElementSpace | Premiers pas, Régime stationnaire |
| modèle | déclarer la physique, ses blocages, ses chargements | pyrucast.model | Régime stationnaire, Élasticité, Contact |
| matériaux | donner les coefficients que le modèle réclame | pyrucast.element_field.material_field | Régime stationnaire |
| second membre | intégrer les chargements | pyrucast.node_field.external_forces | Régime stationnaire |
| assemblage | construire la matrice du système | pyrucast.matrix.stiffness | Régime stationnaire |
| résolution | résoudre le système | pyrucast.solver.solve | Régime stationnaire, Élasticité |
| exploitation | tracer, extraire, exporter, sauvegarder | plot, pyrucast.evolution.along, pyrucast.export.export_vtk, pyrucast.save | Maillage, 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 :
- élasticité isotrope, orthotrope ou anisotrope ;
- plasticité de von Mises (parfaite ou avec écrouissage isotrope), Drucker-Prager et Ottosen, Gurson ;
- fluage et viscoplasticité (Norton, Lemaitre, Blackburn, Chaboche) ;
- endommagement de Mazars, Damage TC et SiC/SiC ;
- dilatation thermique, grandes transformations, dynamique.
É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
- Dans Python :
help(pc.mesh.line)affiche l’aide d’une fonction (voir Python & conventions pyrucast). Les éditeurs de code la montrent aussi, et complètent les noms au fil de la frappe. - Ce livre : les parties Objets, Détails des physiques et Détail des opérateurs décrivent chaque brique, avec sa théorie.
- La référence de l’API Rust : https://pyrucast.github.io/pyrucast/rust/.
- Des exemples complets, un par sujet : dossier
examples/du dépôt.
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 :
- 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 ;
- 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,borderrend 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 ») oux[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 maillagePOI1, 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.15passe en kelvins,u + dumet à jour un déplacement.-champchange 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 * umultiplie une matrice par un champ : c’est ainsi que s’écrit le pas en tempsC / dt + Kde 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 fonctionshow; son corps est le bloc décalé.mesh,title,file,wireframesont ses paramètres ;wireframe: bool = Falselui donne une valeur par défaut.: pc.Mesh,: str,-> Nonedisent 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 yest un choix en une ligne : le chemin du fichier siOUTest défini,None(donc une fenêtre) sinon.- Une fonction rend un résultat par
return.structured_meshen rend deux,return grid, volume_structure, quetutoriel/thermique.pyreç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 :
| objet | contient | une zone |
|---|---|---|
Mesh | un maillage | SubMesh |
FiniteElementSpace | l’interpolation choisie sur un maillage | SubFiniteElementSpace |
NodeField | des valeurs aux nœuds | SubNodeField |
ElementField | des valeurs aux points de Gauss | SubElementField |
Model | une physique, ses conditions, ses chargements | SubModel |
Matrix | une matrice creuse | SubMatrix |
Evolution | des 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 :
| module | rend | exemples du tutoriel |
|---|---|---|
pyrucast.mesh | un maillage | line, arc, circle, extrude, sweep, triangulate_surface, skin, border, select, elements_on, points_on_cylinder, to_poi1 |
pyrucast.node_field | un champ aux nœuds | positions, restrict, external_forces |
pyrucast.element_field | un champ aux points de Gauss | material_field, gradient, integrate_behavior |
pyrucast.model | un modèle | heat_conduction, boundary_transfer, flux, dirichlet, elasticity |
pyrucast.matrix | une matrice | stiffness, 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é :
| module | activité | exemple |
|---|---|---|
pyrucast.solver | résoudre un système | solve |
pyrucast.measure | mesurer un nombre (une intégrale…) | integral |
pyrucast.export | écrire pour un autre logiciel | export_vtk |
pyrucast.coords | déplacer les nœuds du magasin | displace |
pyrucast.field | les fonctions mathématiques, sur tout champ | sqrt, cos, exp |
pyrucast.stepping | le calcul pas à pas | step_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 :
- son premier argument est l’objet que l’opération transforme ;
- elle rend un objet pyrucast ;
- elle a un sens pour n’importe quel objet de ce type.
| fonction | mé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éplacementsu_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 cible | place ses propres nœuds à l’intérieur | quelconque |
| structuré | un nombre d’éléments | balaie une ligne sur une autre | grille |
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_volumeprévient surstderret les nomme : le maillage renvoyé contient un second sous-maillage, dePOI1, à côté desTET4.element_types()vaut donc['TET4', 'POI1']et non['TET4']. Tout ce qui parcourt les sous-maillages ou compte des mailles doit prendre leTET4seul. 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
| Sollicitation | Où | Valeur |
|---|---|---|
| température imposée | l’alésage | 250 °C |
| flux imposé | la face gauche, \( x = 0 \) | −40 kW/m² (il sort) |
| convection | la face du dessous, \( z = 0 \) | \( h = 240 \) W/m²/K, \( T_f = -80 \) °C |
| source volumique | la 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
- Régime stationnaire — \( [C]\{\dot{T}\} \) disparaît : un seul système, résolu deux fois, la conduction seule puis toutes les sollicitations.
- 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 :
| Terme | pyrucast |
|---|---|
| \( \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 versrestrict_like. Il faut d’abord ramener les champs sur un support commun —pyrucast.node_field.restrictsur un même maillage retombe sur le supportPOI1canonique de ce maillage, doncrestrict(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 avecrestrict_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 :
| Terme | pyrucast |
|---|---|
| \( [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 temps | pc.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 :
| Terme | pyrucast |
|---|---|
| \( \{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érations | pc.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 :
| point | sans rayonnement | avec rayonnement | écart |
|---|---|---|---|
| A, coin bas gauche | 40,25 °C | 21,73 °C | −18,52 °C |
| B, bord du haut | 228,69 °C | 205,52 °C | −23,17 °C |
| C, avant le trou | 222,98 °C | 214,07 °C | −8,91 °C |
| D, la pointe | 249,99 °C | 249,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.
- La page Élasticité pose cette masse de trois façons et lit les déplacements, les réactions et les contraintes.
- La page Dilatation, matériau homogène ajoute la dilatation due à la température calculée au chapitre précédent.
- La page Dilatation, matériau hétérogène la reprend avec un coefficient de dilatation qui varie le long de la pièce.
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
| Sollicitation | Où | Valeur |
|---|---|---|
| encastrement | la face gauche, \( x = 0 \) | \( u = 0 \) |
| masse suspendue | l’alésage | 2 500 kg, soit un poids \( mg \) de 24 525 N |
| dilatation (9.7.2 et 9.7.3) | toute la pièce | la 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
- É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.
- 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.
- 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\} \) :
| Cas | Effort | \( \{F_s\} \) |
|---|---|---|
| 1. force ponctuelle | \( -mg \) selon \( z \), au bas de l’alésage | une 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 :
| Cas | Réaction verticale |
|---|---|
| force ponctuelle | 24 525,0 N |
| pression | 15 613,1 N |
| force surfacique | 24 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 :
| Terme | pyrucast |
|---|---|
| \( T \) aux points de Gauss | pc.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 \) max | 0,760 mm | 1,416 mm |
| point le plus bas, \( u_z \) min | −1,280 mm | −1,585 mm |
| von Mises max aux points de Gauss | 379,19 MPa | 534,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
- 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.
- 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 :
- \( \sigma_y = 250 \) MPa, la même à toute température ;
- \( \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_FINALetN_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 pas | 2 jusqu’à \( t = 45 \), puis de 4 à 8 | 2 jusqu’à \( t = 45 \), puis de 4 à 18 |
| itérations en tout | 83 | 98 |
| \( p \) max au dernier pas | \( 2{,}8321 \times 10^{-3} \) | \( 3{,}2316 \times 10^{-3} \) |
| von Mises max au dernier pas | 250,00 MPa | 249,42 MPa |
| point le plus bas | −1,672 mm | −1,759 mm |
| réaction verticale | 24 525,0 N | 24 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 :
- 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 de rotation : il reste unique, et les mailles qui le touchent sont des triangles au lieu de quadrangles ;
- 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’encastrement | 13 829,6 N | 24 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.
| pyrucast | primales / 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 maillageSEG2/TRI3/QUA4selon 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 profilsTRI3/QUA4non parallèles ;pyrucast.mesh.triangulate_volume(enveloppe, taille)— remplissageTET4d’une enveloppeTRI3fermé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 deCoordset 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: unCtrl+Cou 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
| Couche | Rôle | Ne dépend pas de |
|---|---|---|
containers/ | structures de données + invariants | ops/, py/ |
ops/ | opérateurs croisant des conteneurs | py/ |
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ément | atoms/element_kind/<nom>.rs + 2 lignes dans atoms/element_kind/mod.rs + 2 dans atoms/element_type.rs (guide) |
| une physique | models/<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érateur | ops/<conteneur produit>/<nom>.rs + son wrapper py/ops/<même module>.rs + son ré-export dans python/pyrucast/<même module>.py |
| un objet conteneur | containers/<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 danspython/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 vuesSub*. 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, jamaisself. Piège à connaître :Cells’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 vueSub*.
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.
| module | produit |
|---|---|
mesh | un Mesh (mailleurs, transformations, select) |
node_field | un NodeField (dérivations, assemblage nodal) |
element_field | un ElementField (cinématique, matériaux, comportement) |
matrix | une Matrix (les assembleurs) |
model | un 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 :
- le premier argument est le sujet — l’objet qu’on transforme ;
- le retour est un conteneur — sinon il n’y a rien à composer ;
- 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 :
Portablen’est pas exposé côté Python — c’est une brique interne. La sérialisation depuis Python passe parpyrucast.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 :
- Struct Rust adressable par un
Handle<T>typé. - Les trois niveaux de listing :
Display(résumé),Debug(structure),Dump(contenu). - Tests unitaires Rust, et un doctest portant un exemple exécutable sur
chaque item public —
ignoreproscrit,no_runsi l’exemple ne peut pas tourner. - Binding PyO3 (
__str__/__repr__/dump()). - Tests Python (
tests/python/), la surface pyo3 comprise. - 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 —
pyo3etpyo3-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::…::Fooest exposée sous le même nompyrucast.Foo(le wrapper PyO3 internePyFooest masqué) ; - une fonction libre
ops::<module>::fest 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) ;solverest l’exception nommée. Seuls trois verbes vivent au top-level, faute de conteneur produit :saveetload(cf.archive) etspill_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]desrc/lib.rs(enregistrement des classes et fonctions) et le stubpython/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 Rust | Structure Rust | Classe Python | Chapitre |
|---|---|---|---|
coords | Coords | pyrucast.Coords | Coords |
atoms::node | Node | pyrucast.Node | Nœud |
containers::mesh | SubMesh | pyrucast.SubMesh (vue, via mesh[i]) | Maillage |
containers::mesh | Mesh | pyrucast.Mesh | Maillage |
atoms::cell | Cell | pyrucast.Cell | Maillage |
containers::finite_element_space | SubFiniteElementSpace | pyrucast.SubFiniteElementSpace | Espace EF |
containers::finite_element_space | FiniteElementSpace | pyrucast.FiniteElementSpace | Espace EF |
atoms::element | Element | pyrucast.Element | Espace EF |
containers::node_field | SubNodeField | pyrucast.SubNodeField (vue, via node_field[i]) | Champ aux nœuds |
containers::node_field | NodeField | pyrucast.NodeField | Champ aux nœuds |
containers::element_field | SubElementField | pyrucast.SubElementField (vue, via element_field[i]) | Champ aux points de Gauss |
containers::element_field | ElementField | pyrucast.ElementField | Champ aux points de Gauss |
containers::matrix | SubMatrix | pyrucast.SubMatrix (vue, via matrix[i]) | Matrice creuse |
containers::matrix | Matrix | pyrucast.Matrix | Matrice creuse |
containers::model | SubModel | pyrucast.SubModel (vue, via model[i]) | Modèle physique |
containers::model | Model | pyrucast.Model | Modèle physique |
containers::evolution | SubEvolution | pyrucast.SubEvolution (constructible — voir ci-dessous) | Évolution |
containers::evolution | Evolution | pyrucast.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>) -> Mesh | from_live_nodes(coords) -> Mesh |
poi1_from_nodes(nodes: &[Node]) -> Mesh | poi1_from_nodes(nodes) -> Mesh |
line(a: &Node, b: &Node, n_elems: usize, element_type: ElementType) -> Mesh | line(a, b, n_elems, element_type=None) -> Mesh |
circle(center: &Node, normal: &[f64], radius: f64, n_elems: usize, element_type: ElementType) -> Mesh | circle(center, normal, radius, n_elems, element_type=None) -> Mesh |
arc(node_a: &Node, center: &Node, node_b: &Node, n_elems: usize, element_type: ElementType) -> Mesh | arc(a, center, b, n_elems, element_type=None) -> Mesh |
sweep(mesh_a: &Mesh, mesh_b: &Mesh, n_layers: usize, element_type: ElementType) -> Mesh | sweep(mesh_a, mesh_b, n_layers, element_type=None) -> Mesh |
transfinite(side1: &Mesh, side2: &Mesh, side3: &Mesh, side4: &Mesh, element_type: ElementType) -> Mesh | transfinite(side1, side2, side3, side4, element_type=None) -> Mesh |
sweep_solid(mesh_a: &Mesh, mesh_b: &Mesh, n_layers: usize) -> Mesh | sweep_solid(mesh_a, mesh_b, n_layers) -> Mesh |
extrude(mesh: &Mesh, direction: &[f64], n_layers: usize) -> Mesh | extrude(mesh, direction, n_layers) -> Mesh |
revolve(mesh: &Mesh, angle: f64, n_layers: usize, center: &[f64], axis: Option<&[f64]>) -> Mesh | revolve(mesh, angle, n_layers, center, axis=None) -> Mesh (une ligne qui touche l’axe s’y referme en TRI3 / TRI6) |
to_quadratic(mesh: &Mesh) -> Mesh | to_quadratic(mesh) -> Mesh |
convert(mesh: &Mesh, target: ElementType) -> Mesh | convert(mesh, element_type) -> Mesh |
copy(mesh: &Mesh, new_nodes: bool) -> Mesh | copy(mesh, new_nodes=True) -> Mesh |
translate(mesh: &Mesh, vector: &[f64]) -> Mesh | translate(mesh, vector) -> Mesh |
rotate(mesh: &Mesh, angle: f64, center: &[f64], axis: Option<&[f64]>) -> Mesh | rotate(mesh, angle, center, axis=None) -> Mesh |
symmetry_point(mesh: &Mesh, center: &[f64]) -> Mesh | symmetry_point(mesh, center) -> Mesh |
symmetry_line(mesh: &Mesh, a: &[f64], b: &[f64]) -> Mesh | symmetry_line(mesh, a, b) -> Mesh |
symmetry_plane(mesh: &Mesh, a: &[f64], b: &[f64], c: &[f64]) -> Mesh | symmetry_plane(mesh, a, b, c) -> Mesh |
project_on_sphere(mesh: &Mesh, center: &[f64], radius: f64) -> Mesh | project_on_sphere(mesh, center, radius) -> Mesh |
triangulate_surface(contour: &Mesh, element_type: ElementType, target_size: Option<f64>) -> Mesh | triangulate_surface(contour, element_type, size=None) -> Mesh |
pave_surface(contour: &Mesh, element_type: ElementType, target_size: Option<f64>, prefer_quads: bool, relax: FrontRelax) -> Mesh | pave_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) -> Mesh | grid_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) -> Mesh | grid_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) -> Mesh | triangulate_volume(envelope, size=None, allow_surface_nodes=False) -> Mesh |
pave_volume(envelope: &Mesh, layers: usize, thickness: Option<f64>, core_size: Option<f64>) -> Mesh | pave_volume(envelope, layers=1, thickness=None, size=None) -> Mesh |
border(mesh: &Mesh, angle_deg: Option<f64>) -> Mesh | border(mesh, angle_deg=None) -> Mesh |
skin(mesh: &Mesh, angle_deg: Option<f64>) -> Mesh | skin(mesh, angle_deg=None) -> Mesh |
orient(mesh: &Mesh) -> Mesh | orient(mesh) -> Mesh |
invert(mesh: &Mesh) -> Mesh | invert(mesh) -> Mesh |
chain(mesh: &Mesh) -> Mesh | chain(mesh) -> Mesh |
barycenter(mesh: &Mesh) -> Mesh | barycenter(mesh) -> Mesh |
to_poi1(mesh: &Mesh) -> Mesh | to_poi1(mesh) -> Mesh |
elements_on(mesh: &Mesh, points: &Mesh, strict: bool) -> Mesh | elements_on(mesh, points, strict=True) -> Mesh |
points_in_sphere(mesh: &Mesh, center: &[f64], radius: f64, tol: Option<f64>) -> Mesh | points_in_sphere(mesh, center, radius, tol=None) -> Mesh |
points_on_sphere(mesh: &Mesh, center: &[f64], radius: f64, tol: Option<f64>) -> Mesh | points_on_sphere(mesh, center, radius, tol=None) -> Mesh |
points_on_plane(mesh: &Mesh, origin: &[f64], normal: &[f64], tol: Option<f64>) -> Mesh | points_on_plane(mesh, origin, normal, tol=None) -> Mesh |
points_below_plane(mesh: &Mesh, origin: &[f64], normal: &[f64], tol: Option<f64>) -> Mesh | points_below_plane(mesh, origin, normal, tol=None) -> Mesh |
points_on_line(mesh: &Mesh, a: &[f64], b: &[f64], tol: Option<f64>) -> Mesh | points_on_line(mesh, a, b, tol=None) -> Mesh |
points_in_cylinder(mesh: &Mesh, base: &[f64], top: &[f64], radius: f64, tol: Option<f64>) -> Mesh | points_in_cylinder(mesh, base, top, radius, tol=None) -> Mesh |
points_on_cylinder(mesh: &Mesh, base: &[f64], top: &[f64], radius: f64, tol: Option<f64>) -> Mesh | points_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>) -> Mesh | points_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>) -> Mesh | points_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>) -> Mesh | points_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>) -> Mesh | points_on_torus(mesh, center, axis, major_radius, minor_radius, tol=None) -> Mesh |
merge_nodes(mesh: &Mesh, tol: f64, in_place: bool) -> Mesh | merge_nodes(mesh, tol, in_place=False) -> Mesh |
cleanup(mesh: &Mesh) -> Mesh | cleanup(mesh) -> Mesh |
merge_triangles(mesh: &Mesh) -> Mesh | merge_triangles(mesh) -> Mesh |
regularize(mesh: &Mesh, sweeps: usize, angular: bool, in_place: bool) -> Mesh | regularize(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) -> Imported | from_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) -> ElementType | element_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) -> Mesh | consolidate(mesh) -> Mesh |
select_nodes(field: &NodeField, band: &Band, …) -> Mesh / select_cells(field: &ElementField, …) -> Mesh, et pour une zone select_sub_nodes / select_sub_cells | select(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>>) -> NodeField | positions(mesh, components=None) -> NodeField |
divergence(field: &ElementField, prefix: &str) -> NodeField | divergence(field, prefix) -> NodeField |
restrict(field: &NodeField, mesh: &Mesh) -> NodeField | restrict(field, mesh) -> NodeField |
project_node(field: &NodeField, mesh: &Mesh, target: &Mesh, tol: f64) -> NodeField / project_element(field: &ElementField, …) -> NodeField | project(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) -> NodeField | restrict_like(field, target) -> NodeField |
merge(a: &NodeField, b: &NodeField) -> NodeField | merge(a, b) -> NodeField |
consolidate(field: &NodeField) -> NodeField | consolidate(field) -> NodeField |
mask(field: &NodeField, band: &Band, …) -> NodeField / mask_sub(sub: &SubNodeField, …) -> SubNodeField | mask(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) -> NodeField | internal_forces(model, state, solution, materials) -> NodeField, aussi model.internal_forces(state, solution, materials) |
external_forces(model: &Model, materials: &ElementField) -> NodeField | external_forces(model, materials) -> NodeField |
sum_to_nodes(field: &ElementField) -> NodeField | sum_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) -> ElementField | gradient(field, fespace) -> ElementField |
deformation(u: &NodeField, fespace: &FiniteElementSpace, measure: StrainMeasure) -> ElementField | deformation(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) -> ElementField | interp_to_gauss(field, fespace) -> ElementField |
thermal_strain(temperature: &ElementField, material: &ElementField, fespace: &FiniteElementSpace, t_ref: f64, measure: StrainMeasure) -> ElementField | thermal_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) -> ElementField | strain(model, solution, materials) -> ElementField (EPSI : l’entrée de chaque loi ; aussi model.strain(…)) |
elastic_stress(model: &Model, strain: &ElementField, materials: &ElementField) -> ElementField | elastic_stress(model, strain, materials) -> ElementField (σ = D:ε ; aussi model.elastic_stress(…)) |
von_mises(model: &Model, stress: &ElementField, coefficients: &[f64; 6]) -> ElementField | von_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) -> ElementField | elastic_strain(model, stress, materials) -> ElementField (ε = D⁻¹:σ ; aussi model.elastic_strain(…)) |
stress_measure(stress: &ElementField, gradient: &ElementField, target: StressMeasure) -> ElementField | stress_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) -> ElementField | rotate_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) -> ElementField | cell_integral(field, target) -> ElementField (target : un point par maille, quadrature REDUCED) |
shell_deformation(field: &NodeField, fespace: &FiniteElementSpace, formulation: ShellFormulation) -> ElementField | shell_deformation(field, fespace, formulation) -> ElementField |
beam_deformation(field: &NodeField, fespace: &FiniteElementSpace, material: &ElementField) -> ElementField | beam_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) -> ElementField | consolidate(field) -> ElementField (fusionne les zones d’une même fespace) |
mask(field: &ElementField, band: &Band, …) -> ElementField / mask_sub(sub: &SubElementField, …) -> SubElementField | mask(field, ge=None, …) -> ElementField ; accepte aussi un SubElementField |
sub_material_field(sub: &SubModel, components_and_values: &[(&str, f64)]) -> SubElementField | sub_material_field(sub_model, components_and_values) -> SubElementField |
material_field(model: &Model, components_and_values: &[(&str, f64)]) -> ElementField | material_field(model, components_and_values) -> ElementField |
material_field_per_sub_model(model: &Model, per_sub_model: &[&[(&str, f64)]]) -> ElementField | material_field_per_sub_model(model, components_and_values_per_sub_model) -> ElementField |
behavior::initial_state(model: &Model, materials: &ElementField) -> ElementField | initial_state(model, materials) -> ElementField (l’état de repos, prev du premier pas) |
behavior::integrate(model, deformation, prev: &ElementField, materials, dt: Option<f64>) -> ElementField | integrate_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]) -> Evolution | along(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) -> f64 | integral(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) -> f64 | xty(x, y) -> float (dispatch par type ; produit scalaire global de deux champs) |
SubField::xtx(&self) -> f64 / Field::xtx(&self) -> f64 | xtx(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) -> T | psca(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) -> T | mê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 /unionRust) ; l’arithmétique champ + scalaire et champ + champ par les opérateurs+,-,*,/,**(cf. Opérateurs). Ni l’une ni l’autre n’est une fonctionops—merge(a, b)est juste un alias nommé dea | 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) -> Model | heat_conduction(fespace, symmetry="isotropic") -> Model |
fick(fespace: &FiniteElementSpace, species: &str, symmetry: MaterialSymmetry) -> Model | fick(fespace, species, symmetry="isotropic") -> Model |
radiation(fespace: &FiniteElementSpace, target: &Model) -> Model | radiation(fespace, target) -> Model |
flux(fespace: &FiniteElementSpace, target: &Model, dual: &str) -> Model | flux(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>) -> Model | boundary_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) -> Model | interface_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) -> Model | truss(fespace, strain_measure="linear") -> Model |
elasticity(fespace: &FiniteElementSpace, kinematics: Kinematics, symmetry: MaterialSymmetry, strain_measure: StrainMeasure) -> Model | elasticity(fespace, kinematics, symmetry="isotropic", strain_measure="linear") -> Model |
plasticity_perfect(fespace: &FiniteElementSpace, kinematics: Kinematics, strain_measure: StrainMeasure) -> Model | plasticity_perfect(fespace, kinematics, strain_measure="linear") -> Model |
plasticity(fespace: &FiniteElementSpace, kinematics: Kinematics, law: PlasticLaw, strain_measure: StrainMeasure) -> Model | une 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) -> Model | mazars(fespace, kinematics, strain_measure="linear") -> Model |
damage(fespace: &FiniteElementSpace, kinematics: Kinematics, law: DamageLaw, strain_measure: StrainMeasure) -> Model | une 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) -> Model | bernoulli(fespace) -> Model |
timoshenko(fespace: &FiniteElementSpace) -> Model | timoshenko(fespace) -> Model |
shell(fespace: &FiniteElementSpace, formulation: ShellFormulation) -> Model | shell(fespace, formulation) -> Model ("thick" ou "kirchhoff") |
dirichlet(imposed_mesh: &Mesh, target: &Model, variable: &str, sense: RelationSense) -> Model | dirichlet(imposed_mesh, target, variable, sense="=") -> Model |
mpc(terms: &[(&Mesh, &str, f64)], target: &Model, sense: RelationSense) -> Model | mpc(terms, target, sense="=") -> Model — terms : liste de (mesh, variable, coefficient) |
embedded(immersed: &Mesh, host: &Mesh, target: &Model, variables: Vec<String>, tol: f64) -> Model | embedded(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>) -> Model | contact(slave, master, target, variables) -> Model |
update_contact(model: &Model, displacement: &NodeField) -> Model | update_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) -> Matrix | stiffness(model, materials) -> Matrix |
mass(model: &Model, materials: &ElementField) -> Matrix | mass(model, materials) -> Matrix |
lump(m: &Matrix) -> Matrix | lump(matrix) -> Matrix |
geometric(model: &Model, materials: &ElementField, stress: &ElementField) -> Matrix | geometric(model, materials, stress) -> Matrix |
tangent(model, materials, deformation, prev: &ElementField, dt: Option<f64>) -> Matrix | tangent(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) -> NodeField | solve(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) -> NodeField | les 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) -> Exported | to_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) -> Objects | load(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::geomhébergelocate_points(mapping iso-paramétrique inverse, sous le baignage) etproject_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) etops::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éthodemesh.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).
| Classe | Opérateurs / méthodes Python | Sémantique | Backing Rust |
|---|---|---|---|
SubNodeField / SubElementField | f + s, f - s, f * s, f / s, f ** s | broadcast scalaire, nouveau champ | Add/Sub/Mul/Div<f64>, map_all |
SubNodeField / SubElementField | f + g, f - g, f * g, f / g, f ** g | champ + champ par composante, union/passthrough (même support), nouveau champ | SubField::merge_components |
NodeField / ElementField | f + s, f - s, f * s, f / s, f ** s | broadcast scalaire sur toutes les zones | Field::combine_scalar |
NodeField / ElementField | f + g, f - g, f * g, f / g, f ** g | champ + champ par (support, composante), union/passthrough | Field::merge_field |
NodeField / ElementField | f + 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égat | add_to_component(c, s), sub_/mul_/div_to_component | scalaire sur une composante, en place | SubField/Field::map_component |
SubElementField | set_uniform(c, v) | force une composante à v | SubField::set_uniform |
f + s/f + grenvoie un nouveau champ ;+=n’est pas surchargé. La composition de zones n’est pas sur+: c’est l’union|(unionen 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 + gviamerge_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.
| Classe | Opérateurs / méthodes Python | Sémantique | Backing Rust |
|---|---|---|---|
Matrix | k * field | produit matrice-vecteur A·x, NodeField neuf | Matrix::mul_field, Mul<&NodeField> |
Matrix | k * 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ée | Mul/Div<f64> for &Matrix, Mul<Matrix> for f64 |
Matrix | -k | facteur nié, sucre pour k * -1.0 | Neg for &Matrix |
Matrix, SubMatrix | a + b, a - b | somme : blocs des deux opérandes, partagés, non dédoublonnés ; résultat non assemblé | Add/Sub (toutes combinaisons Matrix/SubMatrix) |
SubMatrix | b * s, s * b, b / s, -b | bloc 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é
| Classe | Opérateurs Python | Clé | Backing Rust |
|---|---|---|---|
SubNodeField | f[nid, "c"], f[nid, "c"] = v | (NodeId, composante) | Index/IndexMut<(NodeId, &str)> |
SubElementField | f[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
| Classe | len(x) | x[i] → | Backing Rust |
|---|---|---|---|
Cell | nombre de nœuds | Node | méthodes |
SubMesh | nombre de mailles | Cell | méthodes |
Mesh | nombre de sous-maillages | SubMesh | Aggregate (macro) |
SubFiniteElementSpace | nombre d’éléments | Element | méthodes |
FiniteElementSpace | nombre de sous-espaces | SubFiniteElementSpace | Aggregate (macro) |
NodeField | nombre de sous-champs | SubNodeField | Aggregate (macro) |
ElementField | nombre de sous-champs | SubElementField | Aggregate (macro) |
Model | nombre de sous-modèles | SubModel | Aggregate (macro) |
Matrix | nombre de sous-matrices | SubMatrix | Aggregate (macro) |
SubMatrix | nombre d’entrées posées une à une (add_entry) ; 0 pour un bloc assemblé par éléments | — | méthode entry_count |
Evolution | nombre de sous-évolutions | SubEvolution | Aggregate (macro) |
SubEvolution | nombre 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) :
- Déduplication par handle : un sous-objet dont le
Handledésigne un objet déjà présent (cf.Handle::same_object) n’est pas ajouté deux fois. - 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).
| Python | Rust | Résultat | Sémantique |
|---|---|---|---|
agrégat | agrégat | a.union(&b) | agrégat | union dédupliquée, ordre de 1ʳᵉ apparition |
agrégat | sub | a.union_sub(&h) | agrégat | ajoute un sous-objet en queue (ignoré si déjà présent) |
sub | agrégat | a.union_sub_first(&h) | agrégat | la même union, sous-objet en tête (via __ror__) |
sub | sub | T::union_subs(&a, &b) | agrégat | union des deux sous-objets |
node | node | a.union(&b) | Mesh | maillage POI1 unitaire sur les deux nœuds |
mesh | node | m.union_node(&n) | Mesh | ajoute 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
| type | où | ce qu’il prouve | lancé par |
|---|---|---|---|
| test unitaire | #[cfg(test)] mod tests dans src/**.rs | le comportement d’une unité, publique ou privée | cargo test — check_rust |
| doctest | commentaires /// et //! dans src/**.rs | que l’exemple documentant un item compile et tourne | cargo test --features viz — check_rust |
| test d’intégration | tests/*.rs | une chaîne complète vue de l’extérieur du crate | cargo test — check_rust |
| test Python | tests/python/*.py | la surface pyo3 et le comportement côté Python | pytest — check_python |
| garde-fou | tests/python/test_method_exposure.py, test_mirror_completeness.py | une invariante d’API, pas un comportement | pytest — check_python |
| exemple | examples/*.py | une chaîne utilisateur de bout en bout | run_examples — check_examples |
| script du tutoriel | tutoriel/*.py | un parcours pédagogique complet | run_examples — check_examples |
| banc | benches/*.rs | une performance, jamais une correction | cargo 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
TempDirdu modulecommon(tests/common/mod.rs, déclaré parmod 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 prendtempfile.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 Rust | un doctest sur l’item | rien — il est dans la rustdoc | cargo test --doc |
| une chaîne Rust complète (une physique) | tests/<sujet>.rs, entre ancres | un include ancré | cargo test |
| une chaîne Python complète | examples/<sujet>.py | un include, entier ou ancré | run_examples |
| un parcours pédagogique | tutoriel/<sujet>.py, entre ancres | un include ancré | run_examples |
| l’usage d’un opérateur en Python | tests/python/test_doc_<famille>.py, entre ancres | un 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 promesse | rien, 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é danspyproject.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.chdirdonneraient — écrire des fichiers sous des noms courts sans polluer le dépôt — s’obtient en trois lignes au niveau module :tempfile.TemporaryDirectory(), unos.chdiren tête, et surtout unos.chdirde retour en fin de fichier. Omettre la restitution déplacerait tous les fichiers de test collectés ensuite. C’est le procédé desauvegarde.md,installation.mdetvisualization.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 quemdbook testle compilerait. Il faudrait alors le remettre danscheck_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 :
| surface | conforme | écrit à la main |
|---|---|---|
| blocs Rust du book | 69 | 0 |
| blocs Python du book | 188 | 0 |
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.
| attribut | effet | quand |
|---|---|---|
| (aucun) | compile et exécute | le cas normal, à viser toujours |
no_run | compile, n’exécute pas | ouvre une fenêtre, dure une minute, écrit un fichier |
compile_fail | doit échouer à compiler | documenter ce que le typage interdit |
should_panic | doit paniquer | documenter une précondition |
ignore | ne 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 deCoords, 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ègle | garde-fou | où |
|---|---|---|
| tout opérateur Rust a son binding Python | test_mirror_completeness.py | check_python |
| tout verbe éligible a sa méthode | test_method_exposure.py | check_python |
| les exemples et le tutoriel tournent | run_examples | check_examples |
| les doctests compilent et tournent | cargo test --doc | check_rust |
| la rustdoc n’a aucun lien cassé | cargo doc avec RUSTDOCFLAGS="-D warnings" | check_doc |
| les includes du book résolvent | doc_lint.py includes | check_doc |
| aucune page ne possède de code | doc_lint.py fences | check_doc |
| la prose ne cite pas de symbole disparu | doc_lint.py symboles | check_doc |
| tout item public a un exemple | doc_lint.py doctests | check_doc |
| toute entrée Python est citée par un exemple | doc_lint.py api-python | check_doc |
| chaque équation arrive intacte à MathJax | doc_lint.py equations | check_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
Coordstant que leCoordsexiste. - 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é par | Cache | Utilité |
|---|---|---|
| une zone de maillage | son 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 maillage | la table « nœud → ligne » | lire ou écrire la valeur d’un nœud sans le chercher — Maillage |
| un sous-espace éléments finis | la coloration des mailles | assembler et accumuler aux nœuds en parallèle, sans conflit d’écriture — Espace éléments finis |
| un modèle | le motif de chaque genre de matrice | réassembler (une itération de Newton) sans reconstruire la structure de la matrice — Modèle |
| une matrice | sa factorisation | ré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 :
Arctient un compteur de propriétaires. Cloner unArcne copie pas l’objet, cela augmente le compteur ; quand le dernierArcdisparaît, l’objet est détruit.RwLock(de la bibliothèqueparking_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 auCoords. - Aucune référence n’est périmée. Tenir un handle garde l’objet en vie :
readetwritene peuvent pas échouer. - L’identité, c’est l’adresse.
same_objectdit 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 :
- 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. - 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.
| Objet | Membre | Type | Vidé par |
|---|---|---|---|
SubMesh | poi1_companion | OnceLock<Handle<SubMesh>> | toute modification de la connectivité |
SubMesh | node_index | OnceLock<HashMap<NodeId, usize>> | toute modification de la connectivité |
SubFiniteElementSpace | coloring | OnceLock<Vec<Vec<usize>>> | rien : les zones d’un espace sont scellées |
Model | matrix_patterns | un OnceLock<Arc<AssemblyPattern>> par genre de matrice | add_sub, add_subs |
Matrix | factorization | Mutex<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
parallelré-exporte le prelude rayon et fixe la politique de grain (MIN_PARALLEL_LEN, viawith_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 :
- 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). - Aucune allocation dynamique — ni
Vec, niString, niformat!, 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→Coordsest l’ordre de verrouillage de tout le dépôt, jamais l’inverse, etSubMesh::to_poi1le 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
_withexistent pour ça —SubMatrix::add_entry_with,SubNodeField::nodes_with: elles reçoivent le&SubMeshque 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
| Outil | Version | Rôle |
|---|---|---|
Rust (via rustup) | ≥ 1.89 (rust-version du crate), édition 2024 | Compilation du cœur |
| Python | ≥ 3.11 (3.13 testé) | API Python et maturin |
ruff | 0.16.4 (la version épinglée en CI) | Format et règles du Python (ruff format, ruff check), vérifiés par check_format |
mdbook | 0.5.4 (la version épinglée en CI) | Génération de cette documentation |
mdbook-mermaid | 0.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) oupython3-devel(Fedora/RHEL).pyo3en 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 nipyo3nilibpython: ni Python nipython3-devne 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, oucargoavec--features python-api/stub-gen),pyo3cherche l’interpréteur viaVIRTUAL_ENV: activez toujours le venv avant d’invoquer ces commandes. Uncargo build/cargo testpur (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-depsexclut la doc des dépendances (pyo3,serde,bincode,nalgebra) — gain de temps majeur.--liblimite à la cratepyrucast(sans les tests d’intégration).--openouvre 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.
| Feature | Apport | Implique | Quand l’activer |
|---|---|---|---|
python-api | tire la dépendance pyo3 + compile le code des #[pyclass]/#[pyfunction] (toute l’API Python) | dep:pyo3 | rarement à la main ; activée automatiquement par extension-module et stub-gen. La désactiver (défaut) donne un crate Rust pur. |
extension-module | python-api + dit à pyo3 de ne pas se lier à libpython (l’interpréteur hôte la fournit au chargement du .so) | python-api, pyo3/extension-module | systématique pour maturin develop / maturin build. |
viz | export PNG/SVG (rendu CPU via plotters) | — | scripts headless, captures pour la doc, CI. |
viz-interactive | fenêtre interactive winit/softbuffer (souris, gizmo) | viz | environnement graphique disponible. |
stub-gen | python-api + binaire stub_gen qui produit le stub .pyi | python-api, pyo3-stub-gen | après modification des bindings, pour rafraîchir le stub vu par les IDE. |
extension-modulevsstub-gen.extension-moduleproduit un.sochargé par Python :pyo3se passe alors du link àlibpython. À l’inverse,stub-genproduit un binaire exécutable : il fautlibpythonlinkée normalement, donc on n’active pasextension-modulece 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.
| commande | ce qu’elle teste | où vivent les tests | temps | quick | fmt | rust | py | ex | doc | all | version |
|---|---|---|---|---|---|---|---|---|---|---|---|
cargo fmt --check | le 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-targets | le build Rust pur compile encore | — | <1 s | ✓ | ✓ | ✓ | ✓ | ||||
cargo test --features viz | 1036 tests + 893 doctests | #[cfg(test)] dans 123 fichiers de src/, 42 fichiers tests/*.rs, commentaires /// et //! | 154 s | ✓ | ✓ | ✓ | ✓ | ||||
cargo build --features viz-interactive | la fenêtre winit compile | aucun test — 960 lignes que rien n’exerce sans écran | 14 s | ✓ | ✓ | ✓ | ✓ | ||||
maturin develop --release --features extension-module,viz | l’extension Python s’installe, compilée en release comme la roue | — | 4 s | ✓ | ✓ | ✓ | |||||
python -m pytest | l’API Python, unité par unité | 73 fichiers tests/python/test_*.py, dont 13 test_doc_*.py qui sont aussi les sources des exemples du book | 32 s | ✓ | ✓ | ✓ | |||||
script/run_examples.sh | des chaînes de calcul de bout en bout | 30 examples/*.py, 3 examples/*.rs, 6 tutoriel/*.py | 80 s | ✓ | ✓ | ✓ | |||||
cargo doc --no-deps --lib (-D warnings) | la rustdoc n’a aucun lien cassé | — | 1 s | ✓ | ✓ | ✓ | |||||
mdbook build book | le book se rend | book/src/** | 1 s | ✓ | ✓ | ✓ | |||||
python script/doc_lint.py | les six garde-fous du book et des exemples | book/src/**.md, le book construit, la rustdoc, le module Python installé | 28 s | ✓ | ✓ | ✓ | |||||
cargo clippy × 4 jeux de features | aucun avertissement, -D warnings | — | 105 s | ✓ |
Et les totaux, dans le même régime :
| script | contenu | temps |
|---|---|---|
check_format | formatage seul | 2 s |
check_rust | cœur Rust | 169 s |
check_python | liaison et tests Python | 50 s |
check_examples | exemples et tutoriel | 46 s |
check_doc | rustdoc, garde-fous, book | 50 s |
check_clippy | quatre jeux de features, -D warnings | ~2 min |
check_gmsh | l’interface gmsh (exige pip install gmsh) | 5 s |
check_medcoupling | l’interface MED (exige pip install medcoupling) | 2 s |
check_quick | formatage + Rust — la boucle de commit | ~3 min |
check_all | les cinq blocs | ~6 min |
set_new_version | check_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 fmtetruff format .avant de le lancer, sinon il s’arrête au premier pas ; run_examplesrejoue 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-fou | ce qu’il tient |
|---|---|
includes | chaque {{#include}} résout : fichier, ancre, texte non vide |
fences | aucune page ne possède de code |
symboles | la prose ne cite aucun symbole disparu |
doctests | cliquet : la couverture des doctests ne peut que monter |
api-python | cliquet : toute entrée Python est citée par un exemple exécuté |
equations | chaque é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 :
- Vérification des prérequis —
cargo,python(≥ 3.11), création/activation du venv, installation dematurinetpytest, installation demdbooksi absent (cargo install mdbook). - Compilation + tests —
cargo build,cargo test(unitaires + intégration + doctests),cargo test --doc,cargo test --features viz. - Module Python avec visu interactive —
maturin develop --release --features extension-module,viz-interactive, puispytest. - Documentation —
cargo doc(référence Rust), régénération du stubpython/pyrucast/_pyrucast/__init__.pyi,mdbook build, et un export pydoc HTML de l’API Python (target/python-doc/pyrucast.html). - Vérification finale — le module s’importe et la visualisation est bien
compilée (
Mesh.plotprésent) ; sinon le script échoue. - 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) :
- 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éfixe | Section du journal |
|---|---|
type(portée)!: — n’importe quel type suivi de ! | Ruptures d’API, en tête |
feat | Nouveautés |
fix | Corrections |
perf | Performances |
refactor | Remaniements |
docs | Documentation |
test | Tests |
build, ci, chore | Compilation et outillage |
style | Style |
| tout le reste | Divers |
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 :
| Plateformes | Features compilées | Prérequis | |
|---|---|---|---|
wheel cp311-abi3 | Linux x86_64 (manylinux2014), Windows x86_64, macOS universal2 | extension-module, viz, viz-interactive | aucun — Python ≥ 3.11 |
sdist .tar.gz | tout le reste (ARM, musl, BSD…) | extension-module seul | rustup, 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-rc1reste possible, mais crates.io garderait0.4.0-rc1là où PyPI normalise en0.4.0rc1: le workflow rapproche les deux formes et refuse celles qu’il ne sait pas superposer. Il faudrait aussi élargir la regex detest_version_exposed, qui n’accepte aujourd’hui queX.Y.Z.
Dépannage rapide
error: failed to run the Python interpreter at ...lors d’uncargo build: le venv n’est pas activé, ou unVIRTUAL_ENVobsolète pointe vers un chemin invalide. Réactivez le venv du projet.No module named 'pyrucast'lors depytest:maturin developn’a pas déposé le module. Vérifier queVIRTUAL_ENVest défini, puis relancermaturin develop.mdbook: command not found: installermdbookviacargo install --locked mdbook --version 0.5.4ou un binaire publié.The "mermaid" preprocessor exited unsuccessfully(ou graphes affichés en bloc de code brut) : installer le préprocesseur viacargo install --locked mdbook-mermaid --version 0.17.1. Il doit être sur lePATHau moment demdbook 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 :
src/models/<ma_physique>.rs(nouveau) — une struct portant ses supports, unimpl SubModelKind, un constructeurnew(...), ses tests, et une invocation dephysics_operator!qui déclare l’opérateur public avec sa documentation (calque surtruss.rs, le cas le plus court).src/containers/model.rs— une variante dansenum SubModelet une ligne dansSubModel::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 :
| trait | ce que le sous-modèle fait avant d’appeler la loi | signature | énuméré |
|---|---|---|---|
StatelessLawKind | rien : la loi ne voit ni état, ni dt | stress(ε, matériau) | ElasticLaw |
ReturnMapLawKind | le prédicteur élastique | return_map(σ_essai, prev, matériau, dt) | PlasticLaw |
DirectUpdateLawKind | rien : la loi reçoit ε et l’état de A | update(ε, 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éclare | résout (1× par zone) | consomme | |
|---|---|---|---|
voie point de Gauss (Behavior) | deformation_reads / state_reads | zone_layout → ZoneLayout | integrate_point |
voie matrice (Domain) | material_components / element_state_reads | element_layout → ElementLayout | element_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éclarermaterial_fespace()(+material_components()) — l’assembleur (src/ops/matrix.rs) sélectionne et valide leSubElementFieldautomatiquement — 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éclarerbehavior_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écuteelement_field::strain) +integrate_point(...), la loi de constitution en un point de Gauss.integrate_behaviorest 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 — sonh·aest 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, danszone_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 unContinuum(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 noyauxelement_stiffness,element_mass,element_geometricetelement_tangent_from_stateainsi 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()etmultiplier_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
*_layoutcorrespondant et écrire le noyauelement_*(voir ci-dessous). -
Tangente d’une physique linéaire : pas de noyau à écrire. Déclarer
tangent_is_stiffness()àtruesuffit, 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 nitangent_layoutfait é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 :
MatrixKind | Cast3m | intégrale | layout | noyau |
|---|---|---|---|---|
Stiffness | RIGI / COND | ∫ Bᵀ D B | stiffness_layout | element_matrix |
Mass | MASS / CAPA | ∫ ρ Nᵀ N | mass_layout | element_mass |
Geometric | KSIG | ∫ Gᵀ σ̂ G | geometric_layout | element_geometric |
Tangent | KTAN | ∫ Bᵀ D_alg B | tangent_layout | element_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 internesVAR(A), et pour les lois incrémentales la cinématique ε(A). Il existe toujours : au premier pas, c’est l’état de repos que fabriqueinitial_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.0pour une loi indépendante du temps ; une loi qui en a besoin le reçoit toujours,integrate_behaviorrefusant 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 surcontributions(kind, …)et pilote le matériau via le seamas_domain()(Domain). Aucunmatchpar variante.src/ops/element_field/behavior.rsetmaterial_field.rs, avec leur wrappersrc/py/ops/element_field.rs.src/ops/node_field/internal_forces.rs: passe parbuild_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+ lesimplde capacité qui la concernent). Ajouter la physique n°30 ne touche que 4 endroits (§ Les étapes), dont 2 sont des lignes uniques dansmodel.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 proprematch: 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 macrophysics_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 SubModelse sérialise nativement (indice de variante + payload), zéro code manuel ; - un
Box<dyn SubModelKind>imposeraittypetag, qui ne supporte pas les formats non auto-descriptifs commebincode. 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
SubModelKindla 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 dephysics(), à 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 wrapperstruct 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
matchpar variante du module modèle estas_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 :
src/atoms/element_kind/<mon_element>.rs(nouveau) — une struct unité et sonimpl ElementKind. Calquer surtri3.rs(cas linéaire le plus court),tri6.rs(quadratique, qui délègue son domaine à son parent linéaire) oupyra5.rs(le cas difficile : fonctions de forme rationnelles et quadrature conique).src/atoms/element_kind/mod.rs— unmod <mon_element>;et un bras dansas_kind().src/atoms/element_type.rs— une variante dans l’énum, son rustdoc, et une entrée dansElementType::ALL.
Rien d’autre. En particulier :
- aucun wrapper PyO3 :
ElementTypetraverse la frontière en chaîne, etfrom_namese déduit deALL+name(); nodes_per_cell,topological_dimetfrom_namesurElementTypedé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 quegmsh_permutationetmed_permutationdisent 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.pyexige un volume positif et des nœuds milieux au milieu des arêtes qu’il en déduit ; ajouter le type à sa tableREFERENCE.
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êtekest donc toujours à l’indice localcorner_count() + k.QUA9etHEX27ajoutent 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 :
| Frontend | Jeton | Déclencheur |
|---|---|---|
| Rust pur | NoCancel / () | jamais |
| Rust pur | AtomicBool | un autre thread / handler ctrlc lève le drapeau |
| Rust pur | Deadline | dépassement d’un délai (timeout) |
| Python | PySignals (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→KeyboardInterruptcôté Python.- Sonder une fois par étape grossière ; coût nul pour
NoCancel(inliné).