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)
│
├── NodeField (agrège des SubNodeField) valeurs par nœud × composante
│
└── Mesh (agrège des SubMesh, + ElementType) géométrie
└── 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)
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 |) 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 (NodeId, champ) |
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 mut sm = SubMesh::new(coords.clone(), ElementType::SEG2);
sm.add_cell(&[a.id(), b.id()])?;
// The aggregate does not carry the `Coords`: the submeshes are what hold it.
// `Mesh::from_submesh(sm)` is the shortcut for the single-submesh case.
let mut mesh = Mesh::empty();
mesh.add_sub(Handle::new(sm))?;
println!("{}", mesh); // Mesh: 1 submesh(es), 1 cell(s) total
Ok(())
}
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(c, "SEG2")
mesh.unit().add_cell([a, b])
print(mesh) # Mesh: 1 submesh(es), 1 cell(s) total
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. Le miroir est sans exception : aucune fonction libre ne vit au top-level ; - 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. Même raison pour
internal_forces, qui lit la contrainte de Voigt par nom.
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 |
Quelques types Rust ne sont pas exposés en classes Python : ce sont des
détails d’implémentation (SubModelKind, l’énum des physiques sous 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, 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
Toutes les fonctions ops prennent leurs conteneurs par référence et
renvoient Result<T> (converti en exception Python RuntimeError). Les
signatures ci-dessous omettent le & et le Result pour la lisibilité.
ops::mesh — construction et transformation de maillages
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="SEG2") -> Mesh |
circle(center: &Node, normal: &[f64], radius: f64, n_elems: usize, element_type: ElementType) -> Mesh | circle(center, normal, radius, n_elems, element_type="SEG2") -> 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="SEG2") -> Mesh |
sweep(mesh_a: &Mesh, mesh_b: &Mesh, n_layers: usize, element_type: ElementType) -> Mesh | sweep(mesh_a, mesh_b, n_layers, element_type="QUA4") -> Mesh |
transfinite(side1: &Mesh, side2: &Mesh, side3: &Mesh, side4: &Mesh, element_type: ElementType) -> Mesh | transfinite(side1, side2, side3, side4, element_type="QUA4") -> 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 |
to_quadratic(mesh: &Mesh) -> Mesh | to_quadratic(mesh) -> Mesh |
convert(mesh: &Mesh, element_type: 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 |
triangulate_surface(contour: &Mesh, et: ElementType, size: Option<f64>) -> Mesh | triangulate_surface(contour, element_type, size=None) -> Mesh |
pave_surface(contour: &Mesh, element_type: ElementType, size: Option<f64>, all_quad: bool) -> Mesh | pave_surface(contour, element_type, size=None, all_quad=False) -> Mesh |
triangulate_volume(envelope: &Mesh, 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>, 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 |
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 | 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) |
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 |
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(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) -> NodeField | internal_forces(model, state) -> NodeField |
external_forces(model: &Model, materials: &ElementField) -> NodeField | external_forces(model, materials) -> NodeField |
flux et internal_forces (BSIG, ∫ Bᵀ σ) 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
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 |
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) -> ElementField | deformation(u, fespace) -> ElementField |
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) -> ElementField | thermal_strain(temperature, materials, fespace, t_ref) -> ElementField |
shell_deformation(field: &NodeField, fespace: &FiniteElementSpace, model: ShellModel) -> ElementField | shell_deformation(field, fespace, model) -> 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(field, ge=None, …) -> ElementField ; accepte aussi un SubElementField |
sub_material_field(sub: &SubModel, pairs: &[(&str, f64)]) -> SubElementField | sub_material_field(sub_model, components_and_values) -> SubElementField |
material_field(model: &Model, pairs: &[(&str, f64)]) -> ElementField | material_field(model, components_and_values) -> ElementField |
material_field_per_sub_model(model: &Model, per: &[&[(&str, f64)]]) -> ElementField | material_field_per_sub_model(model, components_and_values_per_sub_model) -> ElementField |
behavior::integrate(model, deformation, prev, materials, dt) -> ElementField | integrate_behavior(model, deformation, materials, prev=None, dt=None) -> ElementField (COMP) |
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) -> 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.
Rust (ops::model::…) | Python (pyrucast.model.…) |
|---|---|
heat_conduction(fes: &FiniteElementSpace) -> Model | heat_conduction(fespace, symmetry=None) -> Model |
heat_conduction_with_symmetry(fes, symmetry: MaterialSymmetry) -> Model | idem, via symmetry= |
fick(fes: &FiniteElementSpace, species: &str) -> Model | fick(fespace, species, symmetry=None) -> Model |
fick_with_symmetry(fes, symmetry: MaterialSymmetry, species: &str) -> Model | idem, via symmetry= |
radiation(fes: &FiniteElementSpace, target: &Model) -> Model | radiation(fespace, target) -> Model |
flux(fes: &FiniteElementSpace, target: &Model, dual: String) -> Model | flux(fespace, target, dual) -> Model |
boundary_transfer(fes, target: &Model, components: Vec<(String, String)>) -> Model | boundary_transfer(fespace, target, components) -> Model |
interface_transfer(side_a, side_b, target: &Model, components, tol: f64) -> Model | interface_transfer(side_a, side_b, target, components, tol=1e-9) -> Model — défaut interface_transfer::DEFAULT_TOL |
truss(fes: &FiniteElementSpace) -> Model | truss(fespace) -> Model |
elasticity(fes, model: ElasticityModel) -> Model | elasticity(fespace, model, symmetry=None) -> Model |
elasticity_with_symmetry(fes, model, symmetry: MaterialSymmetry) -> Model | idem, via symmetry= |
plasticity_perfect(fes, model: ElasticityModel) -> Model | plasticity_perfect(fespace, model) -> Model |
plasticity_with_law(fes, model, law: PlasticLaw) -> Model | une fonction par loi : plasticity_isotropic, drucker_prager, ottosen, creep_norton, creep_blackburn, creep_lemaitre, viscoplasticity_chaboche, viscoplasticity_lemaitre_chaboche, gurson — toutes (fespace, model) -> Model |
mazars(fes, model: ElasticityModel) -> Model | mazars(fespace, model) -> Model |
damage_with_law(fes, model, law: DamageLaw) -> Model | une fonction par loi : damage_tc, damage_sic_sic — (fespace, model) -> Model |
bernoulli(fes: &FiniteElementSpace) -> Model | bernoulli(fespace) -> Model |
timoshenko(fes: &FiniteElementSpace) -> Model | timoshenko(fespace) -> Model |
shell(fes, model: ShellModel) -> Model | shell(fespace, model) -> Model |
dirichlet(target: &Model, variable: &str, imposed_mesh, multiplier_mesh, sense: RelationSense) -> Model | dirichlet(target, variable, imposed_mesh, multiplier_mesh, sense=None) -> Model |
mpc(terms: Vec<MpcTerm>, multiplier_mesh, sense) -> Model | mpc(target, terms, multiplier_mesh, sense=None) -> Model |
embedded(target: &Model, immersed, host, variables: Vec<String>, tol) -> Model | embedded(target, immersed, host, variables, tol=None) -> Model |
contact(target: &Model, slave, master, variables: Vec<String>) -> Model | contact(target, slave, master, variables) -> Model |
Les deux plis du catalogue. Rust nomme la symétrie et la loi par une
enum (MaterialSymmetry, ElasticLaw, PlasticLaw, DamageLaw) ; Python n’expose pas
ces enums, et replie donc la symétrie en mot-clé symmetry= et déplie les lois
en une fonction chacune. Le catalogue est le même des deux côtés — 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: Option<&ElementField>, dt: Option<f64>) -> Matrix | tangent(model, materials, deformation, prev=None, dt=None) -> Matrix |
ops::solver — résolution
Rust (ops::solver::…) | Python (pyrucast.solver.…) |
|---|---|
lu::solve(matrix: &Matrix, rhs: &NodeField) -> NodeField | solve(matrix, rhs, method="lu", cache=True, verbosity="silent") -> NodeField |
eliminate::solve(model: &Model, matrix: &Matrix, rhs: &NodeField) -> NodeField | solve_eliminate(matrix, model, rhs, method="lu", cache=True, verbosity="silent") -> NodeField |
unilateral::solve(model: &Model, matrix: &Matrix, rhs: &NodeField) -> NodeField | solve_unilateral(matrix, model, rhs, method="lu", active_set=None, cache=True, max_iter=100, tol=1e-10, verbosity="silent") -> NodeField |
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 |
| — (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 | 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. Voir
Calculs plus gros que la RAM.
archive — sauvegarde et relecture d’un graphe
Les deux seuls verbes qui restent au 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, &[(&str, &dyn ArchiveRoot)]) | save(path, 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 du garde-fou de complétude (tests/python/test_mirror_completeness.py). 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 |
| zone | 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) |
ElementField | nombre de sous-champs | SubElementField | Aggregate (macro) |
Model | nombre de sous-modèles | SubModel | Aggregate (macro) |
Matrix | nombre de sous-matrices | — (pas de [i]) | Aggregate (macro pour len) |
SubMatrix | nombre d’entrées | — | 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 -, *, /) reste l’arithmétique scalaire des
sous-champs (subfield + 2.0 → ajoute la valeur à chaque composante) et a
vocation à porter, à terme, l’addition réelle de champs (valeur au nœud
= somme des deux). Il n’est jamais utilisé pour composer des agrégats —
c’est | qui s’en charge, sans collision.
Installation et démarrage rapide
Cette page suffit pour utiliser pyrucast en Python : quelques commandes, un premier script, et de quoi vérifier que tout fonctionne. 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.
Prérequis
- Rust stable, installé via
rustup. - Python ≥ 3.11 (pour l’API Python ; inutile en Rust pur).
- Linux uniquement : les en-têtes Python —
python3-dev(Debian/Ubuntu) oupython3-devel(Fedora/RHEL).pyo3en a besoin pour l’édition de liens ; sur Windows l’installateur officiel les inclut déjà.
Compilation et installation
À la racine du dépôt cloné. pyo3 localise l’interpréteur Python via la
variable VIRTUAL_ENV : activez toujours le venv avant cargo ou
maturin, sinon la compilation échoue avec error: failed to run the Python interpreter at ....
Linux / macOS (bash)
python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip maturin
maturin develop --release --features extension-module,viz-interactive
Windows (PowerShell)
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). L’option --release compile en optimisé : recommandé pour tout
usage réel (un build debug est typiquement 10× plus lent à l’exécution).
Après toute modification du Rust, relancer simplement maturin develop --release — pas besoin de réinstaller ni de réactiver le venv tant qu’il
reste actif. Les options –features extension-module,viz-interactive
permettent d’activer la visualisation interactive.
Vérification immédiate
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>
Premier script
Un exemple minimal — une Coords 2D, deux nœuds, un maillage POI1 :
import os
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(c, "SEG2") # un sous-maillage
mesh.unit().add_cell([a, b])
# 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")
mesh.plot(save=None if ecran else "apercu.svg")
print(c)
print(mesh) # Mesh: 1 submesh(es), 1 cell(s) total
mesh.dump()
À 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.
Aller plus loin
- Référence API Rust :
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 : comment le code est organisé, comment les objets vivent en mémoire, et les quelques motifs transverses qu’on retrouve partout. 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. Chaque classe Python (pyrucast.Coords,pyrucast.Mesh, …) enveloppe un type Rust ; chaque fonction de module (pyrucast.matrix.stiffness,pyrucast.solver.solve, …) appelle une fonction 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. 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
Les objets se désignent par des handles
Les objets ne se relient pas entre eux par des références Rust directes, mais
par un Handle<T> : une référence comptée munie de son propre verrou
(Arc<RwLock<T>>). Pas de registre à interroger, pas de session à passer — le
handle est l’adresse de l’objet.
Trois propriétés en découlent, et elles structurent tout le reste de la librairie :
- Libération automatique.
Clonepartage,Droprelâche. Quand le dernier handle d’un objet disparaît, l’objet est détruit — aucune fonctionremove()à appeler. - Toujours valide. Détenir un handle maintient l’objet en vie : il n’y a
pas de référence périmée, et
read/writene peuvent pas échouer. - L’identité, c’est le pointeur.
same_objectdit si deux références désignent le même objet — la base de l’union des agrégats.
L’accès passe par un guard (read / write) qui verrouille ce seul objet
le temps de l’opération (RAII). Le détail — guards possédés, granularité du
verrouillage, compteur par nœud — est dans
Modèle mémoire.
Refcount à deux niveaux
La gestion de durée de vie opère à deux échelles indépendantes :
- au niveau objet : le
Handle<T>décide si un objet entier (unCoords, unSubMesh…) est vivant ; - au niveau interne : à l’intérieur d’un
Coords, un second compteur par nœud décide si tel nœud est vivant. Le ramasse-miettes manuelCoords::gc()opère sur ce niveau-là.
C’est pourquoi un nœud reste protégé tant qu’un maillage ou un champ le
référence, même si tous les Node utilisateurs ont disparu. Voir
Coordonnées et Nœud.
Le motif agrégat / sous-objet
La plupart des conteneurs viennent par paires : un objet zone
(Sub…) et son agrégat (une liste de zones partageant la même grammaire
d’accès) :
| Zone | Agrégat |
|---|---|
SubMesh | Mesh |
SubFiniteElementSpace | FiniteElementSpace |
SubNodeField | NodeField |
SubElementField | ElementField |
SubModel | Model |
SubMatrix | Matrix |
SubEvolution | Evolution |
Tous les agrégats exposent la même interface (len, [i], itération,
unit()) et la même composition par union | (côté Rust : union). Les
sous-objets ne se construisent pas directement : on construit au niveau
parent, et on indexe (parent[i]) pour obtenir une vue sur une zone. Ce
motif est factorisé dans le trait Aggregate — voir Agrégat.
Union
|, pas+. Composer deux zones, c’est l’union (mesh_a | mesh_b), avec partage des sous-objets (refcount) et déduplication par handle. L’opérateur+est réservé à l’arithmétique des champs (cf. Champ).
Trois niveaux d’affichage
Chaque objet implémente trois vues, du plus court au plus complet :
__str__(RustDisplay) — résumé une ligne, façon listing cast3m ;__repr__(RustDebug) — vue structurelle bornée, pour le développement ;dump()— contenu intégral (valeurs, topologie) imprimé sur la sortie standard, au-delà de ce quereprmontre.
Détail dans Conventions.
Erreurs
Toute l’API publique renvoie Result<T, PyrucastError>. Côté Python,
PyrucastError est converti automatiquement en RuntimeError. Il n’y a qu’un
seul type d’erreur dans la librairie — voir Conventions.
Persistance portable
Un trait unique, Portable (serde + bincode), fixe le contrat d’octets :
un format binaire identique Linux ↔ Windows. Au-dessus, 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.
Voir Sauvegarde et relecture, Conventions
et Modèle mémoire.
Pour le développeur
L’organisation des fichiers Rust (où vit chaque morceau) est décrite dans Arborescence.
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 | rend |
|---|---|---|
triangulate_surface | Delaunay contraint + raffinement de Ruppert | des TRI3 |
pave_surface | front avançant, en rangées depuis le bord | des QUA4, quelques TRI3 |
grid_surface | cœur en grille + bande frontale au bord | des QUA4, quelques TRI3 |
grid_surface2 | idem, lignes prises une par nœud du contour | des QUA4, quelques TRI3 |
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 | 98 | 60 | 60 |
| pire | 0,425 | 0,465 | 0,999 | 0,999 | |
| plaque, marche sur la grille | mailles | 394 | 138 | 80 | 80 |
| pire | 0,489 | 0,290 | 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 avec quatre coutures diagonales — sa faiblesse est là où deux de ses rangées se rencontrent, et cette ligne-là 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 | 136 | 86 | 80 |
| pire | 0,481 | 0,187 | 0,405 | 0,963 | |
| L à cotes quelconques | mailles | 308 | 117 | 76 | 70 |
| pire | 0,459 | 0,446 | 0,437 | 0,979 | |
| L étiré à 1,02 | mailles | 319 | 117 | 80 | 74 |
| pire | 0,477 | 0,551 | 0,351 | 0,606 | |
| L étiré à 1,10 | mailles | 336 | 122 | 80 | 74 |
| pire | 0,474 | 0,446 | 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 :
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 | 860 | 474 | 456 |
| pire | 0,412 | 0,366 | 0,382 | 0,916 | |
| base d’un seul tenant | mailles | 2 089 | 863 | 486 | 456 |
| pire | 0,419 | 0,337 | 0,323 | 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 | 620 | 460 | 450 |
| pire | 0,484 | 0,491 | 0,420 | 0,548 | |
| carré arrondi | mailles | 1 866 | 668 | 424 | 409 |
| pire | 0,474 | 0,360 | 0,340 | 0,371 | |
| cercle R = 1 | mailles | 6 114 | 2 339 | 1 236 | 2 044 |
| pire | 0,424 | 0,031 | 0,366 | 0,005 |
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 : rien ne pose de ligne entre y = 0,24 et y = 1, le vide y
vaut quinze fois l’écartement moyen, et le cœur s’effondre — 0,005, une maille
au bord de la dégénérescence. grid_surface, lui, rend 0,288 avec un 5ᵉ
centile de 0,796, de loin le meilleur des quatre. Sur une courbe, prenez
grid_surface, ou triangulate_surface si la qualité du pire élément
commande.
Le cercle mérite un mot de plus. pave_surface y tombe à 0,031 — ses
rangées se rejoignent au centre en une étoile à quatre branches, et c’est cette
ligne-là qui porte tout le défaut. triangulate_surface, lui, ne descend jamais
sous 0,42, 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, grid_surface2 ≤ grid_surface <
pave_surface < triangulate_surface, avec un facteur quatre entre les
extrêmes à qualité au moins égale. Pour un calcul, c’est le facteur qui compte
juste après la qualité. Le cercle est la seule exception, et il l’est dans les
deux colonnes à la fois : grid_surface2 y rend plus de mailles pour une
qualité effondrée, ce qui est 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 ; - 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 ; - quadrangles obligatoires —
pave_surfaceaccepteall_quad=Trueet refuse par une erreur claire un contour dont la parité l’interdit ; - 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 à 600 mailles au
lieu de 400, les plus intérieures à 0,43 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 une courbe, il n’y a pas d’angle à
préserver et le front a tout à gagner à se redresser :
| forme (taille 1) | "free" | "along" | "none" |
|---|---|---|---|
| carré 20 × 20 | 600 mailles, pire 0,541 | 400, 1,000 | 400, 1,000 |
| L | 374, 0,315 | 287, 0,449 | 290, 0,564 |
| profil crénelé | 859, 0,240 | 620, 0,546 | 554, 0,441 |
| bande étroite 40 × 3 | 581, 0,650 | 557, 0,662 | 559, 0,564 |
| cercle R = 10 | 569, 0,105 | 633, 0,257 | 639, 0,020 |
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. Mesuré sur la maison :
l’abandon coûte un triangle là où le remplissage en coûtait cinq, et le 5ᵉ
centile monte de 0,644 à 0,676.
C’est aussi ce qui coûte le cercle, dont les vides valent quinze fois
l’écartement moyen. Aucun seuil ne sépare les deux cas — abandonner un vide de
huit moyennes abandonne a fortiori un vide de quinze — 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, déplacer la grille d’un millième change la pire maille du simple au décuple. 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.
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
│
├── Mesh (agrège des SubMesh)
│ └── FiniteElementSpace (agrège des SubFiniteElementSpace)
│ ├── ElementField (champ aux points de Gauss)
│ └── Model (agrège des SubModel : physiques)
│ └── Matrix (matrice creuse, sortie d'assemblage)
└── NodeField (champ aux nœuds)
Evolution (agrège des SubEvolution : valeur tabulée vs variable, interpolée)
Une distinction structure toute cette liste et se lit dans l’arborescence :
Coords est le magasin (src/coords.rs), Node et Cell 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 (handles, refcount, motif zone/agrégat), voir Aspect informatique. Pour les écrire dans un fichier et les relire tels qu’ils étaient, Sauvegarde et relecture.
Coordonnées (Coords)
Un Coords héberge une ou plusieurs configurations (jeux de coordonnées)
pour le même ensemble de nœuds, en dimension fixée. C’est le premier objet du
modèle pyrucast — tous les autres (Mesh, NodeField, FE space…) viennent s’y
greffer. L’accesseur utilisateur d’un nœud, le Node, fait
l’objet du chapitre suivant.
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.
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().unwrap();
assert_eq!(axi.dim(), 2);
assert!(axi.is_axisymmetric());
}
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é à l’ajout (et au 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 la balaie pour dessiner le corps de révolution
lui-même — voir
Visualisation.
Identité d’un nœud
Chaque nœud créé reçoit un identifiant interne stable (NodeId), unique
pour toute la vie du Coords : aucun id n’est jamais réutilisé, même après
ramassage par le GC. C’est ce qui permet aux maillages et champs de référencer
un nœud par son id sans s’inquiéter de la stabilité.
Politique de suppression : pas de suppression directe
Il n’existe aucune méthode remove_node. Un nœud référencé est protégé.
Seul le ramasse-miettes Coords::gc() retire les nœuds dont le refcount
interne est tombé à 0.
use pyrucast::handle::Handle;
#[test]
fn un_noeud_survit_tant_qu_on_le_tient() {
let coords = Handle::new(Coords::new(2).unwrap());
// add_node initializes refcount = 1; without a decrement, the node is protected.
let id = coords.write().add_node(&[0.0, 0.0]).unwrap();
assert_eq!(coords.write().gc(), 0);
// Après décrément, gc ramasse.
coords.write().decref(id).unwrap();
assert_eq!(coords.write().gc(), 1);
}
Modèle de refcount à deux niveaux
Handle<Coords> ◀── refcount de l'Arc
│ (le Coords est-il vivant ?)
▼
┌──────────────────┐
│ Coords │
└──────────────────┘
│
│ refcount par nœud
▼ ◀── refcount sur chaque NodeId interne
NodeId(0) NodeId(1) … (le nœud est-il vivant ?)
Les deux niveaux sont indépendants :
- tant qu’un
Handle<Coords>existe, leCoordsreste en mémoire ; - tant qu’au moins un
Node(ou un objet aval comme Mesh / Field viaincref/decref) référence unNodeId, ce nœud est protégé du GC.
Le détail du niveau objet est dans Modèle mémoire ; le niveau nœud, dans Nœud.
Plusieurs configurations
Utile pour basculer entre référence / déformée / prédite. La configuration
active est désignée par index ; lire les coordonnées d’un nœud (node.position()
en Python, Coords::position côté Rust) renvoie celles de la configuration
active. add_config(name) clone la configuration active sous un nouveau nom.
Rust :
#[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]);
}
Python :
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
Pourquoi plusieurs configurations dans un Coords plutôt que plusieurs Coords ?
cast3m suit historiquement la convention inverse : un objet de coordonnées = une seule configuration, et on multiplie les objets. Les deux modèles ont des compromis distincts.
| Modèle | Avantages | Limites |
|---|---|---|
Plusieurs configurations dans un Coords (pyrucast actuel) | NodeId 42 désigne le même nœud physique dans toutes les configurations. Les maillages et champs restent valides quelle que soit la configuration active (référence / déformée / prédite). Topologie, refcount, permutation mutualisés. select(config) est un simple changement d’index — pas de remapping aval. | Toutes les configurations ont le même cardinal de nœuds. Pas de configuration “partielle” ne couvrant qu’une portion du domaine. |
Un Coords par configuration (modèle cast3m historique) | Chaque objet est autonome : sérialisation et GC indépendants. Permet des jeux de tailles différentes (sous-problèmes, maillages adaptés). | NodeId 42 dans coords_A ≠ NodeId 42 dans coords_B : tout maillage ou champ référençant plusieurs Coords doit porter une table de correspondance explicite. Source classique de bugs (« nœud copié au lieu de partagé »). Ajouter un nœud « à tous les Coords équivalents » est une opération transverse non triviale. |
Rien n’énumère les Coords vivants — chacun n’est atteignable que par les
handles qui le désignent (cf. Modèle mémoire) : propager un
add_node « à tous les Coords équivalents » n’aurait de toute façon aucun
point d’entrée.
Choix pyrucast : un seul jeu d’identités par domaine géométrique, plusieurs
configurations pour les variantes (référence / déformée / prédite). Des
Coords distincts restent prévus pour les vrais domaines indépendants
(sous-domaines, maillages adaptés de plus haute densité).
Permutation solveur
Une permutation optionnelle (Vec<u32>, longueur = capacity) sépare
l’ordre solveur de l’identité : permutation[node_id] donne l’ordre
solveur associé. Elle est posée par l’appelant aujourd’hui ; une renumérotation
réduisant la bande/profil (Cuthill–McKee) la calculera. L’identité (NodeId)
n’est jamais modifiée, dans les deux cas.
Rust :
#[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 (the automatic computation is still to be written).
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());
}
Python :
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])
# Set a permutation by hand.
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
API Python
import pyrucast
c = pyrucast.Coords(dim=2)
n = c.add_node([0.0, 0.0]) # n is a pyrucast.Node; refcount = 1
m = c.add_node([1.0, 0.0])
print(c) # Coords: dim=2, configs=1 (active="default"), nodes=2 ...
n.set_position([0.5, 0.5])
# GC touches nothing as long as at least one Python Node exists.
assert c.gc() == 0
# del + collect force le Drop côté Rust et libère le refcount.
import gc as pygc
del n
pygc.collect()
assert c.gc() == 1
Méthodes d’inspection utiles : node_count() (nœuds vivants), capacity()
(slots alloués, vivants + non encore collectés), is_alive(id) et
refcount(id) — qui prennent un id brut (pas un Node) : un Node
portant un refcount ne pourrait jamais être observé mort. acquire(id) rend un
Node supplémentaire pour un id existant (refcount += 1). dump() imprime le
contenu intégral (coordonnées, configurations) sur la sortie standard.
Nœud (Node)
Le Node est l’accesseur utilisateur d’un nœud d’un Coords.
Il ne stocke pas de coordonnées : il porte un Handle<Coords> et un NodeId,
et délègue tout au Coords (les coordonnées lues dépendent donc de la
configuration active). C’est l’objet qu’on passe partout où une API attend un
nœud.
Principe : un identifiant qui se protège
Un Node est conceptuellement une paire (Coords, NodeId), mais avec une
propriété de plus : il maintient le refcount interne automatiquement, par
RAII.
Cloneincrémente le refcount du nœud dans leCoords;Drople décrémente.
Tant qu’un Node (ou un objet aval — un SubMesh, un champ — qui a fait son
propre incref) référence un NodeId, ce nœud est protégé du ramasse-miettes
Coords::gc(). C’est le niveau interne du refcount à deux niveaux décrit
dans Coordonnées.
#[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 m = n.clone(); // refcount = 2
drop(n); // refcount = 1
drop(m); // refcount = 0
coords.write().gc(); // collecte
Ok(())
}
Le code interne peut toujours manipuler directement les NodeId sans passer
par Node, mais perd alors la protection automatique : il doit appeler
manuellement Coords::incref / Coords::decref (c’est ce que font les
maillages et les champs).
Création
Un Node ne se construit jamais « dans le vide » : il naît d’un Coords.
- Rust :
Node::create_in(coords_handle, &[x, y, …])crée le nœud et rend leNode(refcount = 1). Pour obtenir unNodesupplémentaire sur un id déjà existant,Node::acquire(coords_handle, id)— côté Rust le verbe est porté par l’atome, côté Python par le magasin. - Python :
coords.add_node([x, y, …])renvoie directement unpyrucast.Node.coords.acquire(id)rend un accesseur de plus.
Interface
Côté Python, le Node expose :
- la propriété
id— l’identifiant entier stable dans sonCoords; position()— ses coordonnées dans la configuration active ;set_position([x, y, …])— réécrit ses coordonnées (dans la configuration active) ;coords()— leCoordsauquel il appartient ; filet de secours quand la poignée a été lâchée côté Python, commeMesh.coords();- l’union
node | node→ unMeshPOI1 unitaire sur les deux nœuds (la même union|que les agrégats, cf. Agrégat) ; etmesh | nodeajoute un point à unMeshPOI1 unitaire ; - les vues
repr/stretdump().
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).
poi = a | b
print(poi) # Mesh: 1 submesh(es), 2 cell(s) total
Côté Rust, Node expose id(), position(), set_position(&[…]),
coords() (le Handle<Coords> porté), plus Clone/Drop qui gèrent le
refcount comme décrit ci-dessus.
Pourquoi un accesseur protecteur plutôt qu’un simple id ?
Manipuler des NodeId bruts est possible mais dangereux : rien n’empêche un
nœud d’être ramassé pendant qu’on tient encore son id. Le Node rend la
protection automatique et locale — exactement comme un Rc/Arc le ferait
pour un objet du heap, mais ici au niveau du nœud interne d’un Coords. Une
alternative (modèle « cast3m pur » où un nœud n’existe qu’au sein d’un
maillage) est discutée dans Modèle mémoire ; pyrucast
conserve pour l’instant le Node protecteur.
Agrégat
La plupart des conteneurs de pyrucast viennent par paires : un objet
zone (SubMesh, SubFiniteElementSpace, SubNodeField,
SubElementField, SubModel, SubMatrix, SubEvolution) et son agrégat
(Mesh, FiniteElementSpace, NodeField, ElementField, Model, Matrix,
Evolution). Tous les agrégats partagent exactement la même grammaire
d’accès et la même
composition par union — factorisées dans le trait Rust Aggregate
(src/aggregate.rs). Ce chapitre décrit ce contrat commun une fois pour
toutes ; chaque chapitre d’objet n’en redonne que les spécificités.
Principe : une liste de handles
Un agrégat est, au fond, un Vec<Handle<Sub>> : une liste de handles vers
des sous-objets adressés par handle. Il ne copie jamais ses zones — il en
partage les handles (refcount). Le trait Aggregate dérive toute la
mécanique d’accès de deux accesseurs seulement (items() / items_mut()), si
bien qu’ajouter un nouvel agrégat ne duplique aucun code d’indexation.
Mesh FiniteElementSpace Model
├── Handle<SubMesh> ├── Handle<SubFES> ├── Handle<SubModel>
├── Handle<SubMesh> ├── Handle<SubFES> └── Handle<SubModel>
└── … └── …
Interface uniforme
Tout agrégat expose, côté Python :
| Opération | Sens |
|---|---|
len(agg) | nombre de zones |
agg[i] | vue typée sur la zone i (un Sub…) — jamais une copie |
agg[i:j:k] | nouvel agrégat du même type avec le sous-ensemble des zones (slicing Python : pas, bornes négatives) |
for sub in agg: | itère les zones (via le protocole séquence) |
agg.unit() | la seule zone d’un agrégat unitaire, sinon une erreur claire |
agg.add_sub(sub) | ajoute une zone en place |
agg.add_subs(other) | concatène en place toutes les zones d’un autre agrégat (sans déduplication) |
agg | other | union (voir plus bas) |
repr / str / dump() | les trois niveaux d’affichage |
Côté Rust, le trait Aggregate fournit les mêmes : len, is_empty,
get(i), subset(indices), iter, unit, push/add_sub/add_subs, plus
Index<usize> et IntoIterator via la macro impl_aggregate_std_traits!.
agg[i]vue,agg[i:j]agrégat. L’indexation entière renvoie une vue sur une zone (unSub…) ; le slicing renvoie un agrégat du même type queagg, dont les zones sont des handles partagés (pas de copie). Le slicing s’appuie sursubsetcôté Rust et préserve les invariants de l’agrégat (check_push,finalize).
Passer une seule zone à un opérateur. Les opérateurs (
ops/) prennent des agrégats, pas des vues :mesh.invert(me[1])lève unTypeError(«SubMeshobject is not an instance ofMesh»). Pour n’en traiter qu’une, utiliser le slicing, qui rend un agrégat unitaire :mesh.invert(me[1:2]).
unit()vs[0].agg[0]prend silencieusement la première de plusieurs zones ;agg.unit()exige qu’il n’y en ait qu’une et lève sinon. Aux frontières qui n’ont de sens que mono-zone (par exemple ajouter une cellule à un maillage qu’on vient de créer avec un seul sous-maillage), préférerunit()— plus honnête (cf.CONVENTIONS.md).
Les sous-objets ne se construisent pas seuls
Un Sub… obtenu par agg[i] est une vue : on ne le construit pas
directement côté Python. On construit toujours au niveau parent
(Mesh(coords, type), FiniteElementSpace(mesh), ElementField(fes, comps))
ou par un opérateur qui rend un parent (model.heat_conduction(fes)…), puis on
indexe pour atteindre une zone.
Composition : l’union |
Composer deux agrégats, c’est l’union : a | b côté Python, a.union(&b)
côté Rust. La sémantique est uniforme pour les sept agrégats :
- Déduplication par handle. Une zone dont le
Handledésigne un objet déjà présent (Handle::same_object) n’est pas ajoutée deux fois. L’ordre est celui de première apparition. - Partage, pas copie. Les zones retenues sont partagées (refcount), jamais dupliquées en mémoire.
- Contraintes de domaine. Les invariants (même
Coordspour unMesh, etc.) restent vérifiés au moment de l’ajout (check_push). - Finalisation. Un crochet
finalize()tourne en fin d’union : no-op pour la plupart des agrégats, mais les champs le surchargent pour fusionner les zones partageant un même support (voir Champ, Champ aux nœuds).
Les quatre formes de l’union :
| Python | Rust | Résultat |
|---|---|---|
agg | agg | a.union(&b) | union dédupliquée des deux listes |
agg | sub | a.union_sub(&h) | ajoute une zone en queue (ignorée si déjà présente) |
sub | agg | a.union_sub_first(&h) | la même union, la zone en tête |
sub | sub | T::union_subs(&a, &b) | un agrégat neuf portant les deux zones |
L’union est donc acceptée dans les deux sens : sub | agg passe par le
__ror__ de l’agrégat (une zone seule ne sait unir qu’une autre zone), et ne
diffère de agg | sub que par l’ordre des zones. La déduplication et la
finalisation sont identiques — une zone déjà présente est simplement déplacée
en tête.
En place :
add_sub/add_subs. L’union rend un agrégat neuf et laisse ses deux opérandes intacts. Pour modifier l’agrégat courant, les deux primitives bas niveau sontagg.add_sub(sub)(une zone) etagg.add_subs(other)(toutes les zones d’un autre agrégat, dans l’ordre). Elles vérifientcheck_pushmais ne dédupliquent pas et n’appellent pasfinalize: c’est de la concaténation. L’API d’usage pour composer reste|(cf.CONVENTIONS.md).
Plus, pour les nœuds (cf. Nœud) :
| Python | Résultat |
|---|---|
node | node | Mesh POI1 unitaire sur les deux nœuds |
mesh | node | ajoute un point (erreur si Mesh non unitaire POI1) |
|compose,+calcule. L’union est toujours|. L’opérateur+(et-,*,/) est réservé à l’arithmétique des champs (field + 2.0, addition de champs…) — il n’est jamais utilisé pour composer des agrégats. Les deux ne se télescopent donc jamais. C’est un changement par rapport aux toutes premières versions, où la composition passait par+; aujourd’hui+est entièrement libéré pour le calcul.
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
Pourquoi un trait commun ?
Factoriser l’accès et l’union dans Aggregate garantit qu’un sous-maillage,
un sous-espace EF ou un sous-modèle s’indexent, s’itèrent et se composent
strictement de la même façon. Aucun __getitem__ n’est réécrit par type,
aucun comportement d’union ne diverge d’un agrégat à l’autre, et un nouvel
agrégat se câble en deux macros (impl_aggregate! + impl_aggregate_pymethods!).
Le seul point de personnalisation est finalize() (et check_push()), que les
champs exploitent pour leur fusion par support.
Maillage (Mesh / SubMesh)
Le maillage de pyrucast se compose à deux niveaux, selon le motif agrégat / zone :
SubMesh: regroupe toutes les cellules d’un mêmeElementType. Stocke la connectivité à plat (unVec<NodeId>de longueurcell_count × nodes_per_cell).Mesh: agrège plusieursSubMeshliés à la mêmeCoords.
Le cas POI1 est volontairement dégénéré : un sous-maillage POI1 est exactement
une liste de nœuds (un nœud par cellule), ce qui sert de support naturel
aux NodeField.
Ce chapitre décrit l’objet maillage (structure, types d’éléments, refcount). La construction de maillages par des générateurs (
line,triangulate_surface,extrude…) relève des opérateurs de maillage.
Types d’éléments
L’enum ElementType liste les types supportés ; chaque variante porte sa
propre méta-information (nombre de nœuds, dimension topologique, nom court
cast3m).
| Variante | 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 + nœud central) |
Les huit derniers types sont quadratiques (Lagrange-2) : ils reprennent la
numérotation des sommets de leur parent linéaire puis ajoutent les nœuds de
milieu d’arête, dans l’ordre d’arêtes de la convention VTK (voir le rustdoc
d’ElementType). 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 = quadrangle/hexaèdre bi-/tri-quadratiques,
avec nœuds de face et central). Ils se posent avec l’interpolation LAGRANGE2
(cf. Espace éléments finis).
Ajouter un nouveau type d’élément est purement additif : un fichier
src/atoms/element_kind/<nom>.rs et une variante — voir
Ajouter un élément fini.
Cellule (Cell)
mesh.cell(submesh_idx, cell_idx) (ou mesh[submesh_idx][cell_idx]) renvoie
une vue Cell sur une cellule : len(cell) donne son nombre de nœuds et
cell[k] le k-ième Node. C’est l’accès lecture à la connectivité ;
l’ajout passe par submesh.add_cell([...]).
Refcount sur les nœuds — interaction avec le GC
Chaque appel à SubMesh::add_cell incrémente le refcount interne de chaque
nœud dans la Coords (cf. Coordonnées). Le Drop du SubMesh
les décrémente. Tant qu’un SubMesh référence un nœud, le ramasse-miettes
le protège — même si tous les Node utilisateurs ont disparu.
Coords ◀── refcount par NodeId
│ (le nœud est-il vivant ?)
├── Node(s) utilisateur(s) ── chacun +1
└── SubMesh(s) ── chacun +1 par cellule incidente
En cas d’échec partiel d’add_cell (par exemple un nœud déjà ramassé), les
incréments déjà effectués pour la cellule courante sont annulés (rollback
transactionnel à l’échelle d’une cellule).
Construire en bloc. Un add_cell prend le verrou d’écriture de la Coords
et lâche les caches dérivés du sous-maillage : c’est le bon grain pour une
maille posée à la main, mais sur un million de mailles c’est ce prix-là qu’on
paie, pas la connectivité. Un opérateur qui produit un gros maillage passe donc
par SubMesh::from_connectivity(coords, type, connectivité) (côté Rust), qui
valide et incrémente tout le tableau en une seule prise de verrou — une unité
par occurrence, comme toujours — et par Coords::add_nodes pour créer ses
nœuds d’un coup. Tous les opérateurs de maillage l’empruntent : les
balayages (sweep, extrude, revolve, sweep_solid), transfinite, les
copies rigides (translate, rotate, symétries, copy), merge_nodes,
convert, to_quadratic, skin, border, chain, orient, select,
consolidate, barycenter, to_poi1, la lecture gmsh et les mailleurs
frontaux. Un nœud dont la position n’est pas encore arrêtée — celle qu’un front
déplace tant qu’il avance, celle qu’un relâchement va lisser — n’est même plus
créé avant de l’être : il vit comme un rang dans un tableau, et ne devient un
NodeId qu’à la fin.
Scellement (connectivité figée après consommation)
Par convention, un maillage n’est plus modifié une fois construit : un espace éléments finis, un champ ou une matrice indexent ses cellules, et lui ajouter une maille par la suite les laisserait dans un état incohérent.
Cette convention est désormais imposée. Dès qu’un objet autre qu’un
maillage capture un SubMesh et indexe ses cellules (construction d’un
SubFiniteElementSpace, d’un support de SubMatrix…), ce sous-maillage est
scellé : sa connectivité est figée pour toujours. add_cell /
add_cell_taking renvoient alors l’erreur MeshSealed. Un Mesh qui se
contente de contenir le sous-maillage ne le scelle pas — il peut continuer
à grossir tant qu’aucun consommateur ne s’y attache. On teste l’état avec
is_sealed.
Un champ aux nœuds fait exception, parce qu’il n’indexe pas les cellules du maillage : il se pose sur le compagnon POI1 de la zone, et c’est ce compagnon-là qui est scellé. Le maillage donné en argument, lui, reste modifiable ; le modifier lâche son compagnon, si bien qu’un champ construit ensuite se retrouve sur un autre support — les champs déjà construits ne sont pas invalidés pour autant, ils restent définis sur le nuage de nœuds d’avant (voir Champ aux nœuds).
Pour repartir d’un maillage scellé et le modifier à nouveau, on en prend une
copie profonde avec duplicate() : un SubMesh (ou Mesh) neuf, non
scellé, avec la même connectivité (les nœuds sont partagés — même Coords —,
seuls leurs refcounts augmentent). L’opérateur
mesh.copy(m, new_nodes=…) est
la même chose au niveau Mesh, avec le choix supplémentaire de recréer des
nœuds neufs aux mêmes endroits plutôt que de partager ceux de l’original.
mesh = pyrucast.Mesh(c, "TRI3")
mesh.unit().add_cell([a, b, n3])
pyrucast.FiniteElementSpace(mesh) # scelle mesh[0]
assert mesh[0].is_sealed
# mesh[0].add_cell([...]) # → RuntimeError (MeshSealed)
copie = mesh.duplicate() # neuf, modifiable
copie.unit().add_cell([b, n3, n4]) # OK
API Rust
#[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(())
}
API Python
import pyrucast
c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
n3 = c.add_node([0.5, 1.0])
# Mesh(coords, type) creates a mesh with a single submesh; unit() gives the
# view of it, add_cell adds a cell.
mesh = pyrucast.Mesh(c, "TRI3")
mesh.unit().add_cell([a, b, n3])
print(mesh) # Mesh: 1 submesh(es), 1 cell(s) total
print(mesh.element_types()) # ['TRI3']
print(mesh.cell_counts()) # [1]
# Composer plusieurs zones : l'union | (jamais +).
quad = pyrucast.Mesh(c, "QUA4")
# … add_cell … ; combined = mesh | quad
Durée de vie et refcount
Les SubMesh et Mesh portent un effet de bord dans leur Drop : ils
décrémentent le refcount des nœuds qu’ils référencent dans la Coords. Cet
effet a lieu exactement une fois, quand le dernier Handle sur le maillage
disparaît. Le détail est dans le chapitre
Modèle mémoire.
Visualisation
mesh.plot(...) trace le maillage (chaque sous-maillage avec sa propre couleur,
ou coloré par un champ). Voir Visualisation.
Espace éléments finis (FiniteElementSpace)
Ce chapitre couvre FiniteElementSpace et son objet associé SubFiniteElementSpace : la couche éléments finis posée par-dessus la couche géométrique (Mesh / SubMesh). Le Mesh reste purement géométrique ; le FiniteElementSpace lui ajoute la formulation (fonctions de forme, points de Gauss, dérivées). Cette séparation permet de réutiliser un même maillage avec plusieurs formulations, et — plus tard — d’admettre un déplacement du maillage tout en réévaluant correctement les grandeurs physiques.
Architecture
Hiérarchie miroir de celle du maillage :
Mesh FiniteElementSpace
├── SubMesh (ElementType) ├── SubFiniteElementSpace (Interpolation, QuadratureRule)
├── SubMesh ├── SubFiniteElementSpace
└── ... └── ...
SubFiniteElementSpacedétient unHandle<SubMesh>, uneInterpolationet uneQuadratureRule. Il porte les tables de référence précalculées une fois pour toute (points de Gauss, fonctions de forme et dérivées de référence évaluées à ces points).FiniteElementSpacedétient unHandle<Mesh>figé et unVec<Handle<SubFiniteElementSpace>>en correspondance un-pour-un avec les sous-maillages. La topologie (connectivité, types d’éléments) est figée à la construction.
POI1 n’est pas un élément fini au sens classique : un sous-maillage POI1 est rejeté à la construction d’un SubFiniteElementSpace.
Le catalogue détaillé — fonctions de forme, dérivées et quadrature de chaque type — est en section Éléments finis supportés. Ce chapitre-ci décrit la machinerie commune (mapping isoparamétrique, Jacobien, gradients physiques) partagée par tous.
Conventions de l’élément de référence
Chaque ElementType fixe son repère de référence \( \xi \) et la numérotation locale de ses nœuds. Ces conventions sont aussi documentées dans le rustdoc de ElementType et reproduites ici pour référence centrale.
| ElementType | Repère \( \xi \) | Numérotation locale (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) \) — CCW |
QUA4 | \( \xi, \eta \in [-1, +1] \) | \( (-1,-1), (1,-1), (1,1), (-1,1) \) — CCW |
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 CCW vue depuis nœud 3 |
PYRA5 | \( \zeta \in [0, 1] \), \( \xi, \eta \in [-(1-\zeta), +(1-\zeta)] \) | base carrée CCW vue depuis l’apex (nœuds 0..3 en \( \zeta = 0 \)) puis l’apex : \( (-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 inférieur CCW (nœuds 0..2 en \( \zeta = 0 \)) puis triangle supérieur CCW (nœuds 3..5 en \( \zeta = 1 \)) — extrusion d’un TRI3 |
HEX8 | \( \xi, \eta, \zeta \in [-1, +1] \) | face inférieure CCW (nœuds 0..3) puis face supérieure CCW (nœuds 4..7) |
Les types quadratiques partagent le repère et la numérotation des sommets de leur parent linéaire, puis ajoutent les nœuds de milieu d’arête dans l’ordre d’arêtes de la convention VTK (documenté dans le rustdoc d’ElementType) :
| ElementType | Parent | Nœuds de milieu d’arête (dans l’ordre), 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 | 4 arêtes comme 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 | 12 arêtes comme HEX20, puis 6 centres de face (x∓, y∓, z∓, nœuds 20..25) et un centre de volume 26 |
QUA8, HEX20, PENTA15 sont sérendipité (arêtes seulement) ; SEG3, TRI6, TET10, QUA9, HEX27 sont des Lagrange complets (QUA9/HEX27 = tenseurs Q2 complets, avec nœuds de face et central).
Ces conventions sont cohérentes avec celles déjà imposées ailleurs dans le code : orientation CCW des triangles produits par triangulate_surface, ordre HEX8/PENTA6 utilisé par extrude et sweep_solid, ordre des nœuds de milieu d’arête aligné sur VTK (export verbatim) et réaligné à la lecture gmsh.
Théorie : élément isoparamétrique
Transformation géométrique de référence
Soit un élément avec \( n \) nœuds, de coordonnées physiques \( \mathbf{x}_1, \dots, \mathbf{x}_n \in \mathbb{R}^{d_s} \) (avec \( d_s \) la dimension géométrique de la Coords). La transformation géométrique \( \chi : \hat{K} \to K \) qui envoie l’élément de référence \( \hat{K} \) sur l’élément physique \( K \) est interpolée par les mêmes 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 est interpolée exactement comme un champ scalaire. Avec une interpolation Lagrange-1, \( \chi \) est affine sur les simplexes (SEG2, TRI3, TET4) et tri-linéaire sur les tenseurs (QUA4, HEX8).
Fonctions de forme Lagrange-1
Une fonction de forme Lagrange est définie par la propriété de Kronecker : \( N_i(\xi_j) = \delta_{ij} \) aux nœuds de référence. Les formules explicites sur les cinq types supportés sont :
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 (tri-liné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) \]
Une propriété immédiate, vérifiée par les tests unitaires : \( \sum_i N_i(\xi) = 1 \) en tout \( \xi \) (partition de l’unité).
Fonctions de forme quadratiques (Lagrange-2)
L’interpolation Lagrange2 couvre les six types quadratiques. Pour les Lagrange complets, avec les coordonnées barycentriques \( L_i \) :
- 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 \).
Les sérendipité n’ont pas de nœud de face/intérieur :
- QUA8 : 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.
- QUA9 : produit tensoriel complet \( N_i(\xi,\eta) = \ell_{a}(\xi)\,\ell_{b}(\eta) \) des trois fonctions de Lagrange 1D \( \ell_{-}(t)=\tfrac12 t(t-1),\; \ell_0(t)=1-t^2,\; \ell_{+}(t)=\tfrac12 t(t+1) \).
- HEX27 : produit tensoriel 3D \( N_i(\xi,\eta,\zeta) = \ell_{a}(\xi)\,\ell_{b}(\eta)\,\ell_{c}(\zeta) \) des mêmes fonctions 1D.
- HEX20 : 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 permutations).
- PENTA15 : produit du TRI6 par un facteur quadratique en \( \zeta \), avec correction sérendipité aux sommets.
Toutes vérifient Kronecker (\( N_i(\xi_j)=\delta_{ij} \)), partition de l’unité, et leurs dérivées analytiques sont recoupées par différences finies dans les tests.
Fonctions de forme cubiques d’Hermite (Hermite-3)
Les deux familles précédentes sont C⁰ : le champ est continu d’un élément au suivant, sa dérivée ne l’est pas. Cela suffit à toute équation du second ordre, et à rien d’autre. 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 uniquement :
\[ \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 à la fois : à 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’annulent dans les deux. 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.
Deux conséquences structurelles
Quatre fonctions pour deux nœuds. Le nombre de fonctions de forme cesse
d’être le nombre de nœuds ; c’est shape_count() qui le donne, et tous les
tableaux de référence sont dimensionnés dessus. nodes_per_cell() reste ce
qu’il était, et continue de dimensionner la géométrie.
L’élément devient sous-paramétrique. La géométrie d’un SEG2 est un segment
droit, interpolée en Lagrange-1, quel que soit le champ qu’il porte. L’espace
tabule donc deux bases :
| base | longueur | rôle |
|---|---|---|
géométrique (n_at_g) | nodes_per_cell | le jacobien \( \partial x/\partial\xi \) |
de champ (field_n_at_g) | shape_count | l’inconnue |
Elles coïncident pour toute interpolation de Lagrange, et l’accesseur de champ se rabat alors sur la géométrique — d’où un coût mémoire nul dans le cas courant, et aucun consommateur existant à modifier.
Dérivées secondes
Hermite3 est la seule famille à tabuler \( \partial^2 N_i/\partial\xi^2 \),
et c’est cohérent : la courbure est une grandeur primaire pour un élément
C¹, alors qu’une base de Lagrange n’en a aucun usage. Sur SEG2 elles sont
linéaires en \( \xi \) — donc la courbure varie dans l’élément, là où un
SEG2 de Lagrange en donnerait une identiquement nulle.
Le passage au physique est une simple règle de chaîne, le terme \( \partial J/\partial\xi \) disparaissant 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₂ et H₄ portent une pente \( \partial w/\partial\xi \), tandis 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 est délibérément absent des fonctions de forme : c’est exactement
le passage référence → physique que toute autre base traverse, donc il vit là où
tous les autres jacobiens sont appliqués. Écrite autrement — avec un L dans la
fonction de forme — la base cesserait d’être une grandeur de l’élément de
référence, et ne pourrait plus être tabulée une fois par type d’élément.
Ce qui le vérifie
La raideur d’Euler-Bernoulli est le seul oracle capable de falsifier cette base :
\[ 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 classique, à la précision machine. Une erreur dans les
fonctions, dans leurs dérivées secondes ou dans le facteur jacobien des pentes
atterrit dans cette comparaison — ce qui vaut mieux que seize assertions sur la
base seule. L’intégrande étant quadratique en \( \xi \), deux points de Gauss
l’intègrent exactement.
Pas de base du tout : MODEL_EMBEDDED
Un élément structurel dont la matrice élémentaire est une forme fermée — une barre, un portique — n’évalue jamais une fonction de forme. Son intégrale a été faite une fois, au crayon, et seul le résultat figure dans le code.
Déclarer une interpolation de Lagrange sur un tel espace énonce quelque chose que la physique n’utilise pas, et parfois quelque chose de faux : la forme fermée de l’élément de portique exact vient de fonctions cubiques et quadratiques, pas linéaires.
MODEL_EMBEDDED le dit. C’est la troisième combinaison des deux bases :
| espace | géométrie | champ |
|---|---|---|
LAGRANGE1 / LAGRANGE2 | Lagrange | la même |
HERMITE3 | Lagrange-1 | Hermite, 4 fonctions |
MODEL_EMBEDDED | Lagrange | absente, par déclaration |
La géométrie reste entièrement définie — coordonnées, jacobien, mesure — donc toute la plomberie d’assemblage est inchangée : champ matériau, sortie de comportement, layout, coloriage, dispersion parallèle. Seule l’interpolation de l’inconnue n’appartient pas à l’espace.
Le refus a des dents
Un accesseur de champ sur un tel espace erronne, en nommant la situation, plutôt que de rendre la base géométrique. La distinction compte parce que la base géométrique est disponible et paraîtrait plausible : c’est exactement le repli silencieux que cette variante existe pour empêcher.
Ce qui suppose que les consommateurs posent la question au bon endroit. Les opérateurs qui interpolent une inconnue sont donc passés à l’accesseur de champ :
| opérateur | ce qu’il interpole |
|---|---|
models::flux | la fonction test d’une charge répartie |
element_field::deformation | u_r, pour la déformation orthoradiale |
element_field::interp_to_gauss | nodal → points de Gauss |
measure::integral | le champ intégré |
Sur une poutre, le premier est le plus parlant : la charge répartie cohérente
vaut qL/2 et qL²/12 en moment, ce qui suppose de connaître la base. Un
repli linéaire donnerait qL/2 sans moment — plausible, et faux. Refuser est la
bonne réponse.
La visualisation, elle, reste sur la base géométrique, et délibérément : ce qu’on colorie est ce qu’on dessine — un segment droit, une facette plane — donc la couleur doit varier le long du tracé. C’est une image, pas une valeur calculée.
Et ce que la formulation doit alors fournir elle-même
La reconstruction des efforts passe à la charge de la formulation, puisque
l’espace ne peut plus interpoler. beam_deformation évalue donc les
déformations depuis les fonctions de forme de l’élément, ce qui l’oblige à
recevoir le matériau : Φ en dépend. C’est le prix, et il est juste — on ne
peut pas reconstituer la courbure d’une poutre sans connaître sa raideur de
cisaillement.
Dérivées de référence
Les dérivées de référence \( \partial N_i / \partial \xi_k \) suivent par dérivation directe. Pour Lagrange-1 sur les simplexes (TRI3, TET4) et sur SEG2, ces dérivées sont constantes sur l’élément. Sur QUA4 et HEX8 — et sur tous les types quadratiques — elles sont polynomiales en les coordonnées de référence.
En dérivant la partition de l’unité, on obtient également \( \sum_i \partial N_i / \partial \xi_k = 0 \) pour chaque direction de référence \( k \). Cette identité est aussi testée.
Stockage flat
Le buffer plat des dérivées de référence retourné par Interpolation::dshape_dxi(et, &xi) est de longueur \( n_\text{nodes} \times d_r \) (avec \( d_r = \dim \hat{K} \)) et row-major :
\[
\mathtt{dN}[i \times d_r + k] = \frac{\partial N_i}{\partial \xi_k}(\xi)
\]
Théorie : quadrature de Gauss
Pour intégrer une fonction \( f \) sur l’élément physique, on remonte à 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| \) le déterminant (au sens généralisé, défini ci-dessous) du Jacobien. Le couple \( (\xi_g, w_g) \) est la règle de quadrature.
pyrucast utilise une règle « par défaut » par type d’élément, calibrée pour intégrer exactement la matrice de masse Lagrange-1 sur un élément à géométrie droite. Les règles sont :
| ElementType | \( 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 mid-edge sur \( \hat{K} \) : \( (\tfrac{1}{2}, 0), (\tfrac{1}{2}, \tfrac{1}{2}), (0, \tfrac{1}{2}) \), \( w_g = 1/6 \) | \( \deg \le 2 \) |
QUA4 | 4 | Produit tensoriel 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 permutations, \( 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 2 \) |
PENTA6 | 6 | Produit tensoriel 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 tensoriel 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 degré 4 (Dunavant) | \( \deg \le 4 \) |
QUA8 | 9 | Produit tensoriel 3×3 | \( \deg \le 5 \) par direction |
QUA9 | 9 | Produit tensoriel 3×3 | \( \deg \le 5 \) par direction |
TET10 | 11 | Règle de Keast degré 4 (un poids négatif) | \( \deg \le 4 \) |
PENTA15 | 18 | Produit tensoriel TRI6 × Gauss 3 points sur \( \zeta \) | \( \deg \le 4 \) / \( \le 5 \) en \( \zeta \) |
HEX20 | 27 | Produit tensoriel 3×3×3 | \( \deg \le 5 \) par direction |
HEX27 | 27 | Produit tensoriel 3×3×3 | \( \deg \le 5 \) par direction |
Les types quadratiques utilisent une règle exacte pour leur matrice de masse (degré 4) sur géométrie droite ; l’exactitude des règles custom TRI6 et TET10 est vérifiée par des tests d’intégration de monômes.
La somme des poids vaut le volume 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 (vérifié par les tests, pour tous les types à la fois).
Théorie : Jacobien et grandeurs physiques
Jacobien
Le Jacobien de la transformation \( \chi \) est la matrice de 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 \in {0, \dots, d_s - 1} \) parcourt les directions physiques et \( k \in {0, \dots, d_r - 1} \) les directions de référence. Le buffer plat retourné par SubFiniteElementSpace::jacobian(cell, g) suit la convention row-major \( \mathtt{J}[a \times d_r + k] = J{a,k} \).
Cas standard : \( d_s = d_r \)
Quand le maillage et son espace ambiant ont la même dimension (par exemple TRI3 dans une Coords 2D), \( J \) est carrée. Le déterminant ordinaire \( \det(J) \) mesure la dilatation locale du volume ; son valeur absolue intervient dans l’intégration. La fonction SubFiniteElementSpace::det_jacobian retourne \( |\det(J)| \).
La dérivation des fonctions de forme par rapport aux coordonnées physiques utilise 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 manifold : \( d_s > d_r \)
Un sous-maillage peut être plongé dans un espace de dimension supérieure : SEG2 dans une Coords 2D ou 3D (contour, courbe), TRI3 dans une Coords 3D (surface plongée). C’est exactement ce que produit triangulate_surface quand on lui donne un contour 3D plan.
Dans ce cas, \( J \) est rectangulaire (taille \( d_s \times d_r \)). Le déterminant standard n’a plus de sens, mais on peut définir la métrique tirée en arrière :
\[
G(\xi) = J(\xi)^T\, J(\xi) \quad \text{de taille } d_r \times d_r
\]
\( G \) est symétrique définie positive (sous condition de non-dégénérescence). L’élément de mesure devient
\[
d\mu = \sqrt{\det G}\, d\xi
\]
qui s’utilise comme \( |J| \) dans la quadrature. La fonction det_jacobian retourne ce \( \sqrt{\det G} \) — toujours positif par construction.
Le gradient tangent d’un champ sur la surface (la projection du vrai gradient sur l’espace tangent) est donné par la pseudo-inverse :
\[
\nabla_s N_i = J\, G^{-1}\, \nabla_\xi N_i
\]
La fonction SubFiniteElementSpace::dn_dx retourne ces composantes, dans le repère ambiant à \( d_s \) dimensions. Pour \( d_s = d_r \), ces formules se réduisent à celles du cas standard (\( J G^{-1} = J^{-T} \)).
Combinaisons valides
Le couple \( (d_r, d_s) \) possible pour notre v0 :
| ElementType | \( d_r \) | \( d_s \) valides |
|---|---|---|
| SEG2 | 1 | 1, 2, 3 |
| TRI3 | 2 | 2, 3 |
| QUA4 | 2 | 2, 3 |
| TET4 | 3 | 3 |
| PYRA5 | 3 | 3 |
| PENTA6 | 3 | 3 |
| HEX8 | 3 | 3 |
| SEG3 | 1 | 1, 2, 3 |
| TRI6 | 2 | 2, 3 |
| QUA8 | 2 | 2, 3 |
| QUA9 | 2 | 2, 3 |
| TET10 | 3 | 3 |
| PENTA15 | 3 | 3 |
| HEX20 | 3 | 3 |
| HEX27 | 3 | 3 |
\( d_s < d_r \) n’a pas de sens (impossible de définir un élément 2D dans un espace 1D) ; le constructeur de SubFiniteElementSpace rejette ce cas.
Stratégie de stockage : invariant vs variant à la déformation
Le maillage support d’un FiniteElementSpace est topologiquement figé, mais ses coordonnées peuvent évoluer (déplacement de maillage, mise à jour incrémentale). pyrucast scinde donc les grandeurs en deux catégories selon leur invariance vis-à-vis de cette déformation :
| Grandeur | Variant avec les coordonnées ? | Stratégie |
|---|---|---|
| Points de Gauss \( \xi_g \), poids \( w_g \) | non (référence pure) | précalculés dans le SubFiniteElementSpace |
| \( N_i(\xi_g) \), \( \partial N_i / \partial \xi_k(\xi_g) \) | non (référence pure) | précalculés dans le SubFiniteElementSpace |
| Jacobien \( J(\xi_g) \) sur chaque cellule | oui | calculé à la volée |
| \( | J | (\xi_g) \), \( \partial N_i / \partial x_a(\xi_g) \) |
Ce choix donne deux propriétés importantes :
- Empreinte mémoire indépendante du nombre de cellules. Un
SubFiniteElementSpacene stocke que de l’ordre de \( n_g \times n_\text{nodes} \times d_r \) flottants — quelques centaines au plus par sous-espace. À comparer aux GB qu’un précalcul des Jacobiens demanderait sur un maillage 3D fin. - Robustesse au déplacement. Réécrire les coordonnées dans la
Coords(par exemple viaCoords::set_position) suffit à mettre à jour automatiquement toutes les évaluations de \( J \), \( |J| \) et \( \partial N_i / \partial x_a \) — pas d’invalidation à signaler, pas de cache à reconstruire.
Le coût est CPU plutôt que mémoire : chaque appel à jacobian(cell, g) recalcule la somme \( J = \sum_i \mathbf{x}i\, \nabla\xi N_i \). En pratique, l’assemblage matrice-élémentaire procède cellule par cellule : on calcule \( J \), \( |J| \), \( \nabla_x N_i \) une fois par couple (cellule, Gauss), puis on les réutilise pour tous les termes intégrés. Le surcoût reste donc proportionnel à \( n_\text{cells} \times n_g \) — soit le minimum incompressible — et non à \( n_\text{cells} \times n_g \times n_\text{termes} \).
Si une mesure montrait un jour que ce recalcul devient un goulot d’étranglement, un cache invalidé sur incrément d’un compteur de version du Coords pourrait être ajouté sans changer l’API publique — déclenché par la mesure, pas par anticipation.
Validation à la construction
SubFiniteElementSpace::new rejette à la création :
- un sous-maillage de type
POI1(pas de repère de référence) ; - un couple
(ElementType, Interpolation)non supporté (par exempleTRI3+Lagrange2tant queLagrange2n’existe pas) ; - un couple
(ElementType, QuadratureRule)non supporté ; - \( d_s < d_r \) (incompatible avec la définition du Jacobien).
FiniteElementSpace::with (et donc lagrange1, new) vérifie en plus :
- que le maillage contient au moins un sous-maillage ;
- que la longueur de la liste
(interpolation, quadrature)correspond au nombre de sous-maillages ; - que chaque
SubFiniteElementSpacese construit sans erreur.
Le déterminant du Jacobien n’est pas vérifié à la construction : un élément dégénéré ou inversé ne sera détecté qu’à la première évaluation de det_jacobian. Cette validation paresseuse permet de construire un FE space sans toucher aux coordonnées et reste correcte vis-à-vis du déplacement de maillage ultérieur (un élément valide peut devenir dégénéré après un déplacement et inversement).
API Rust
Constructeur principal — Lagrange-1 partout, quadrature de Gauss par défaut :
#[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 :
// affine mapping, det(J) = 4 = 2 × the triangle's physical area (1/2 × 2 × 2).
for g in 0..s.gauss_count() {
let dj = s.det_jacobian(0, g)?;
assert!((dj - 4.0).abs() < 1e-12);
}
Ok(())
}
Constructeur explicite — utile pour mélanger les interpolations / quadratures par sous-maillage :
#[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(())
}
Évaluation des grandeurs sur une cellule :
#[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)?;
// … use these buffers in the element matrix assembly …
let _ = (n, dn, jac, det_j, dn_dx);
}
}
Ok(())
}
Déplacement de maillage : exemple
Après modification des coordonnées dans la Coords, les évaluations à la volée reflètent automatiquement le nouvel état :
#[test]
fn deplacer_un_noeud_change_les_evaluations_a_venir() -> 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 mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
mesh.add_cell(&[a.id(), b.id()])?;
let sub = FiniteElementSpace::lagrange1(&mesh)?.get(0)?;
// Initial SEG2: nodes at x=0 and x=1 → |J| = 0.5 (length 1 over [-1,+1]).
let dj_before = sub.read().det_jacobian(0, 0)?;
assert!((dj_before - 0.5).abs() < 1e-12);
// Stretch: the second node moves to x=4 → |J| = 2.0 (length 4 over [-1,+1]).
coords.write().set_position(b.id(), &[4.0, 0.0])?;
let dj_after = sub.read().det_jacobian(0, 0)?;
assert!((dj_after - 2.0).abs() < 1e-12);
Ok(())
}
API Python
L’objet est exposé sous le nom pyrucast.FiniteElementSpace, avec les
sous-espaces accessibles via pyrucast.SubFiniteElementSpace. Les interpolations et
règles de quadrature sont passées en chaînes de caractères
("LAGRANGE1", "GAUSS").
import pyrucast
c = pyrucast.Coords(dim=2)
n0 = c.add_node([0.0, 0.0])
n1 = c.add_node([2.0, 0.0])
n2 = c.add_node([0.0, 2.0])
mesh = pyrucast.Mesh(c, "TRI3")
mesh.unit().add_cell([n0, n1, n2])
# Default constructor: Lagrange1 + Gauss everywhere.
fes = pyrucast.FiniteElementSpace(mesh)
assert len(fes) == 1 # 1 sous-espace = 1 sous-maillage
sub = fes[0] # typed view of subspace 0
assert sub.element_type == "TRI3"
assert sub.interpolation == "LAGRANGE1"
assert sub.quadrature == "GAUSS"
assert sub.gauss_count() == 3
assert sub.space_dim == 2
assert sub.ref_dim == 2
# Evaluations at a given Gauss point.
for g in range(sub.gauss_count()):
print(sub.gauss_xi(g), sub.gauss_weight(g))
print(sub.n_at_g(g)) # N_i(ξ_g), flat
print(sub.dn_at_g(g)) # ∂N_i/∂ξ_j(ξ_g), flat
# Physical quantities (on the fly) on cell 0.
print(sub.jacobian(0, 0)) # J, flat row-major
print(sub.det_jacobian(0, 0)) # |J|, scalaire
print(sub.dn_dx(0, 0)) # ∂N_i/∂x_a, flat row-major
Variantes de construction :
# Same Lagrange1 + same Gauss for every submesh, explicitly.
fes = pyrucast.FiniteElementSpace(mesh, interpolation="LAGRANGE1", quadrature="GAUSS")
# "Class method" form equivalent to the default constructor.
fes = pyrucast.FiniteElementSpace.lagrange1(mesh)
# (Interpolation, quadrature) explicit per submesh.
fes = pyrucast.FiniteElementSpace.with_choices(mesh, [("LAGRANGE1", "GAUSS")])
Déplacement du maillage : le Jacobien reflète automatiquement les
coordonnées courantes du Coords — pas de cache à invalider.
print(sub.det_jacobian(0, 0)) # |J| initial
# Moving a node → every evaluation to come sees the
# nouvelles coordonnées.
n1.set_position([4.0, 0.0])
print(sub.det_jacobian(0, 0)) # |J| recalculé
Limitations actuelles
- Quatre interpolations :
Lagrange1(types linéaires),Lagrange2(types quadratiquesSEG3,TRI6,QUA8,QUA9,TET10,PENTA15,HEX20,HEX27),Hermite3(C¹, surSEG2seul) etModelEmbedded(aucune base de champ : la formulation possède la sienne). Pour les familles de Lagrange le degré doit correspondre au type d’élément : un maillage quadratique se pose avecinterpolation="LAGRANGE2"(le constructeur par défautLAGRANGE1refuse un type quadratique, et inversement).HERMITE3, lui, se pose sur unSEG2sans en être le degré — c’est le cas sous-paramétrique. Les ordres supérieurs (Lagrange-3…) restent à venir. - Deux quadratures :
QuadratureRule::Gauss(la règle standard par défaut parElementType) etQuadratureRule::Reduced(intégration réduite : un point au centroïde, exact pour les constantes — utilisée par exemple pour le terme de cisaillement de la poutre de Timoshenko, anti-verrouillage). Les variantes d’ordre supérieur viendront comme nouvelles variantes. Voir le tableau croisé quadrature × élément pour la compatibilité couple par couple. POI1n’est pas un élément fini (pas de repère de référence). Un sous-maillage POI1 dans le maillage support fait échouer la construction duFiniteElementSpace.- Pas encore de cache invalidable des grandeurs physiques (\( J \), \( |J| \), \( \nabla_x N_i \)) : tout est recalculé à la volée. Une optimisation à base d’invalidation par compteur de version pourra être ajoutée si la mesure le justifie, sans changement d’API.
Champ (Field / SubField)
Les deux familles de champs de pyrucast — le champ aux nœuds
et le champ aux points de Gauss — partagent un contrat
commun, capturé par deux traits Rust (src/containers/field.rs) :
SubField— une zone : un bloc homogène de valeurs, des composantes nommées + un buffer plat ;Field— le niveau agrégat, blanket-implémenté pour toutAggregatedont la zone est unSubField.
Ce chapitre décrit ce contrat (composantes, statistiques, arithmétique) ; les deux chapitres suivants donnent les spécificités de chaque famille.
Principe du trait
SubField : un bloc homogène
Une zone porte :
- une liste ordonnée de composantes nommées (
"UX","UY","T","k","sigma_xx", …) — au moins une, noms uniques ; - un buffer plat de
f64dans lequel l’indice de composante varie le plus vite (stride = nombre de composantes).
Le contrat est purement structurel : peu importe que les « lignes » du
buffer soient des nœuds (SubNodeField) ou des couples (cellule, point de Gauss) (SubElementField) — dès qu’une zone fournit ses composantes et son
buffer, le trait en dérive le reste : recherche d’une composante par nom,
min/max par composante, opérations scalaires.
SubField (composantes [c0, c1], stride = 2)
values = [ ligne0.c0, ligne0.c1, ligne1.c0, ligne1.c1, … ]
└─ composante varie le plus vite ─┘
Chaque zone connaît aussi son support (support() : un Handle<SubMesh>
pour un champ aux nœuds, un Handle<SubFiniteElementSpace> pour un champ aux
Gauss). Deux zones sont « sur le même support » (same_support) si leurs
handles désignent le même slot — c’est la précondition pour les combiner.
Field : le repli sur les zones
Au niveau agrégat, Field replie les opérations sur les zones :
components()— l’union des composantes des zones (ordre de première apparition) ; une composante peut n’exister que sur certaines zones ;min(c)/max(c)— repliés sur les zones qui définissentc(erreur si aucune) ; sans argument, sur tout le champ, toutes composantes et toutes zones confondues ;view()— une vue zéro-copie (un guard de lecture par zone), utilisée par les opérateurs qui font beaucoup de lectures (gradient, solveur, viz).
Opération arithmétique
L’arithmétique des champs se décline en trois familles, du plus simple au plus contraint.
1. Scalaire (broadcast)
field + s, field - s, field * s, field / s, field ** s renvoient un
nouveau champ où l’opération est appliquée à toutes les valeurs de
toutes les composantes de toutes les zones. Disponible au niveau zone
(SubField) et au niveau agrégat (NodeField / ElementField,
dunders Python __add__, …, __pow__).
scaled = mat * 1.1 # nouveau champ, toutes composantes × 1.1
shifted = u - 5.0 # nouveau champ
energy = u**2.0 # element-wise power (fractional exponent is fine)
+=n’est pas surchargé :f + sne mute pasf. (Côté Rust, la version consommante est zéro-copie, la version par référence clone d’abord.)
Puissance — Python seulement. Rust n’a pas d’opérateur de puissance ;
+/-/*//passent parAdd/Sub/Mul/Div, mais**est exposé par le seul dunder__pow__. Côté Rust, faire une puissance via les primitives génériques :combine_scalar(|a, b| a.powf(b), s)(scalaire) oumerge_components(other, |a, b| a.powf(b))(binaire). La forme ternairepow(x, y, z)(modulo) est refusée — elle n’a pas de sens sur des flottants.
2. Par composante (en place)
Pour ne toucher qu’une composante, sur toutes les zones qui la portent :
add_to_component(c, s), sub_to_component, mul_to_component,
div_to_component — en place, erreur seulement si aucune zone ne
définit c (la division par zéro est refusée). Au niveau zone, set_uniform(c, v) force une composante à une valeur constante.
mat.mul_to_component("E", 0.95) # scales "E" only
3. Binaire entre champs
Combiner deux champs valeur à valeur se décline selon le niveau.
Les opérateurs + - * / (et **), aussi bien de zone à zone
(merge_components) que d’agrégat à agrégat (merge_field), sont par (support,
composante) en union avec passthrough — les deux opérandes n’ont pas
besoin du même jeu de composantes ni de la même décomposition :
- la sortie couvre l’union des supports des deux opérandes ;
- sur un support porté des deux côtés, les zones sont fusionnées composante par
composante (
merge_components) : une composante définie des deux côtés devientop(a, b); une composante d’un seul côté passe telle quelle (passthrough brut, pour tous les opérateurs — donca - bsur une composante propre àbvautb, pas-b) ; - un support porté d’un seul côté voit sa (ses) zone(s) passer inchangées.
Cela suppose l’invariant de champ (au plus une zone par (support, composante),
garanti par l’union |) ; la sortie l’hérite par construction.
« Même support » = le même objet, pas la même géométrie. L’appariement se fait par identité d’objet (
Handle::same_object, viaSubField::same_support), jamais en comparant les nœuds. Deux supports distincts qui portent les mêmes nœuds — ou qui n’en partagent que quelques-uns, comme deux régions de bord adjacentes assemblées chacune de son côté — comptent donc chacun comme « porté d’un seul côté » : les deux zones passent inchangées et se retrouvent côte à côte dans la sortie. Rien n’est sommé au nœud commun, et à la lecture agrégat (gather,value) c’est la première zone définissant(nœud, composante)qui l’emporte — l’autre contribution est perdue sans erreur. Pour additionner deux régions qui se touchent, il faut donc les ramener sur un support partagé au préalable :restrict(a, m) + restrict(b, m)(même maillagem⇒ même supportPOI1canonique, mis en cache), ourestrict_like(b, a)pour retomber sur celui dea(cf. Opérateurs sur les champs).
Tous les opérateurs passent par merge_components (zone) / merge_field
(agrégat) / merge_subfield (maj ciblée d’une zone). Là où un jeu de composantes
divergent est une erreur plutôt qu’un passthrough (p. ex. l’interpolation
Evolution entre deux valeurs tabulées du même champ), on garde merge_components
mais précédé du garde-fou SubField::check_same_components (même support et
même jeu de composantes, sinon erreur).
La même mécanique vaut pour field ** field (puissance élément par élément,
exposant pris dans le second champ). La division — et la puissance à exposant
fractionnaire sur base négative — ne se protègent pas des cas limites à ce
niveau (sémantique numpy : inf / nan).
4. Fonctions unaires (cos, exp, …)
Des fonctions mathématiques de base s’appliquent élément par élément, renvoyant un nouveau champ de même type (zone ou agrégat). Style numpy, exposées au top-level côté Python :
import pyrucast as pc
champ2 = pc.field.cos(champ1) # cosine of every value
e = pc.field.exp(pc.field.abs(u) * -1.0) # they compose freely
norme = pc.field.sqrt(sx**2.0 + sy**2.0)
Jeu disponible : abs, sqrt, exp, log (népérien), log10, cos,
sin, tan, sinh, cosh, tanh. Sémantique non gardée comme le reste
(log d’un négatif → nan). Côté Rust ce sont des fonctions nommées
(ops::field::cos(&f), …, génériques via le trait MapValues) ; il n’y a pas
de syntaxe cos(x) pour un opérateur en Rust, donc seul Python en profite à
l’écriture. Tout repose sur la primitive map_all (cf. plus haut), sans
logique nouvelle.
Interface (résumé)
| Niveau | Méthode | Effet |
|---|---|---|
| zone & agrégat | components() | composantes (union au niveau agrégat) |
| zone & agrégat | min(c) / max(c) | extrema d’une composante — sans argument, de tout le champ |
| zone | set_uniform(c, v) | force c à v |
| zone & agrégat | f + s, f - s, f * s, f / s | scalaire, nouveau champ |
| zone & agrégat | f ** s, f ** g | puissance élément par élément (Python ** ; Rust : merge_components) |
| zone & agrégat | add_to_component(c, s) … | scalaire sur une composante, en place |
| zone & agrégat | f + g, f - g, f * g, f / g | opérateurs binaires : union par (support, composante), passthrough brut |
| zone | merge_components(other, op) | primitive des opérateurs de zone : union par composante, passthrough brut |
| zone | check_same_components(other) | garde-fou : erreur si support/composantes divergent (à appeler avant merge_components quand un écart est un bug) |
| agrégat | merge_field(other, op) | primitive des opérateurs d’agrégat : union par (support, composante) |
| agrégat | merge_subfield(sub, op) | binaire ciblé sur une zone (union/passthrough) |
Les deux familles concrètes ajoutent leurs accès indexés et leurs constructeurs propres :
NodeField/SubNodeField— valeurs par nœud, lecture agrégatfield.value(node, "c"), écriture par zone ;ElementField/SubElementField— valeurs par(cellule, point de Gauss).
Et l’union
|? L’union compose des zones (structure), l’arithmétique combine des valeurs. Pour unElementField, l’union ne fusionne plus les zones : elle valide simplement qu’aucune composante n’est portée deux fois sur le même support (deux zones de même support à composantes disjointes restent côte à côte). Pour unNodeField, l’union finalise encore en fusionnant les zones de même support (et lève si elles divergent sur une valeur partagée). La fusion explicite reste offerte parnode_field.consolidate/element_field.consolidate(cf. Opérateurs sur les champs).
Champ aux nœuds (NodeField / SubNodeField)
Un champ aux nœuds porte une ou plusieurs valeurs par nœud. Il suit la même grammaire d’agrégat que tous les conteneurs de pyrucast (cf. Conventions) :
SubNodeField— les valeurs d’une zone : un bloc multi-composantes sur les nœuds d’un sous-maillage POI1 (cf. Maillage) ;NodeField— l’agrégat : une liste deSubNodeField, un par zone, avec éventuellement des composantes différentes d’une zone à l’autre.
C’est le miroir exact de ElementField / SubElementField côté valeurs aux
nœuds. L’intérêt de l’agrégat : un champ multiphysique (par exemple T sur
tout le domaine, UX/UY sur la zone solide seulement) se représente sans
inventer de 0.0 pour les couples (nœud, composante) qu’aucune zone ne
définit — rien n’est densifié.
Support : un sous-maillage POI1 par zone
Un SubMesh POI1 est, par construction, exactement une liste de nœuds
(un nœud par cellule). Chaque SubNodeField s’appuie sur un support POI1 :
- construit depuis un SubMesh POI1, le champ partage le handle du support tel quel (aucun refcount par nœud supplémentaire : le SubMesh est l’unique propriétaire des increfs, garder son handle suffit à garder les nœuds en vie) ;
- construit depuis un SubMesh d’un autre type d’élément, le champ se pose sur
le compagnon POI1 canonique de la zone — ses nœuds distincts dans
l’ordre de première apparition —, matérialisé une fois et mémoïsé par le
sous-maillage. Deux champs bâtis sur la même zone tombent donc sur le même
support, et s’apparient (cf.
same_support).
Le champ ne stocke aucun identifiant de nœud : un POI1 est la liste de nœuds, et c’est le support qui la détient, une fois pour tous les champs qui s’y posent. Recopier la liste dans chaque champ reviendrait à la stocker autant de fois qu’il y a de champs (et de pas de temps) sur ce support ; le champ ne porte que ses valeurs, et lit les nœuds dans le support quand il en a besoin.
Scellement : le compagnon, pas le maillage
C’est le support qui est scellé — le compagnon POI1 —, jamais le maillage donné en argument. Le champ indexe ses lignes par position dans ce support, donc ce support ne doit plus bouger ; mais la zone d’origine, elle, n’a aucune raison de geler : le champ n’y touche plus.
Modifier cette zone (add_cell, remap_nodes) lui fait lâcher son
compagnon : le nuage de nœuds a changé, le cache n’y répond plus. Conséquence,
un champ construit après la modification se pose sur un nouveau support et
ne s’apparie plus avec les précédents. Ce n’est pas une invalidation : les
champs d’avant gardent leur support en vie et restent parfaitement lisibles,
simplement définis sur le maillage d’avant. Pour les ramener sur le nouveau, un
restrict suffit.
NodeField (agrégat)
├── SubNodeField zone 0 ── support POI1 ── values[i × ncomp + c]
├── SubNodeField zone 1 ── support POI1 ── values[...]
└── …
Composantes nommées
Chaque zone porte ses noms de composantes ("UX", "UY", "T", …),
rangés en row-major : la composante c du nœud i est à l’indice
i × ncomp + c. Au moins une composante par zone, noms uniques, valeurs
initialisées à 0.0. Au niveau agrégat, components() renvoie l’union
des composantes des zones (ordre de première apparition).
Les caractéristiques communes à tous les champs (composantes, min, max,
sum, arithmétique scalaire et par composante) sont portées par les traits Rust
SubField (niveau zone) et Field (niveau agrégat) — partagés
avec ElementField.
Nœuds d’interface : duplication, lecture, cohérence
Un nœud partagé par plusieurs zones (nœud d’interface) est stocké une fois par zone. Trois règles régissent cette duplication :
- lecture agrégat (
field.value(nœud, comp)) : la première zone définissant le couple gagne — aucune vérification au fil de l’eau. La forme par lotfield.values(nœuds, comp)lit une liste de valeurs dans le même ordre :nœudsest une liste de nœuds, unSubMeshPOI1, ou unMeshPOI1 (ses points pris dans l’ordre de la connectivité) ; même règle « première zone » et même erreur qu’un nœud non défini ; - écriture : il n’y a pas d’écriture au niveau agrégat ; toute
mutation passe par les zones (
field[i]), exactement commeElementField; - cohérence à la demande :
field.check()vérifie que toutes les zones stockant un même couple(nœud, composante)portent la même valeur (comparaison exacte) ;node_field.consolidate(field)fait cette vérification puis fusionne par support — les zones définies sur le mêmeSubMesh(identité de handle) deviennent une seule zone portant l’union de leurs composantes (valeurs des composantes communes vérifiées), les supports distincts restent séparés.
Composition : union |
Comme pour tous les agrégats, a | b unit les zones (handles partagés,
pas de copie ; déduplication par handle) — ce n’est pas une addition de
valeurs. L’union finalise en fusionnant les zones de même support (voir
node_field.consolidate ci-dessus) et lève si deux zones divergent sur une valeur
partagée. L’arithmétique scalaire (f + 2.0, f * 0.5, …) vit au niveau
zone (SubNodeField) sur +/*/… Le nommé merge(a, b) ≡ a | b.
API Rust
#[test]
fn un_champ_aux_noeuds_s_ecrit_par_zone_et_se_lit_par_agregat() -> 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])?;
// Support : SubMesh POI1 contenant a et b.
let sm = {
let mut sm = SubMesh::new(coords.clone(), ElementType::POI1);
sm.add_cell(&[a.id()])?;
sm.add_cell(&[b.id()])?;
Handle::new(sm)
};
// Champ de déplacement 2D mono-zone : composantes UX, UY.
let u = NodeField::from_submesh(&sm, vec!["UX".into(), "UY".into()])?;
// Écriture : via la zone. Lecture : via l'agrégat (ou la zone).
u.get(0)?.write().set_value(a.id(), "UX", 1.5)?;
assert_eq!(u.value(a.id(), "UX")?, 1.5);
assert_eq!(u.value(b.id(), "UX")?, 0.0); // valeur par défaut
// From a multi-zone mesh: one SubNodeField per submesh.
let mesh = Mesh::from_submesh(SubMesh::new(coords, ElementType::POI1));
let field = NodeField::new(&mesh, vec!["T".into()])?;
assert_eq!(field.len(), mesh.len());
field.check()?; // zones cohérentes aux interfaces
Ok(())
}
API 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(c, "POI1")
mesh.unit().add_cell([a])
mesh.unit().add_cell([b])
# One SubNodeField per submesh of the support (Mesh or SubMesh).
u = pyrucast.NodeField(mesh, ["UX", "UY"])
print(u) # NodeField: 1 subfield(s)
print(u.unit()) # SubNodeField: 2 node(s), 2 component(s) [UX, UY]
# Écriture via la zone, lecture via l'agrégat.
u[0][a, "UX"] = 1.5
print(u.value(a, "UX")) # 1.5
# Batch read: a list of nodes (or a POI1 Mesh/SubMesh) → an ordered list.
print(u.values([a, b], "UX")) # [1.5, 0.0]
print(u.values(mesh, "UX")) # [1.5, 0.0] — points of the POI1 mesh
print(u.min("UX"), u.max("UX")) # 0.0 1.5
print(u.sum("UX")) # 1.5 — Σ over the nodes (resultant of a force field)
# Components per zone (multiphysics):
f = pyrucast.NodeField.with_components_per_submesh(two_zone_mesh, [["T"], ["UX", "UY"]])
print(f.components()) # ['T', 'UX', 'UY']
f.check() # interface coherence (raises otherwise)
g = pyrucast.node_field.consolidate(f) # merge as tightly as possible
Refcount et durée de vie
Le champ ne fait aucune comptabilité par nœud : il garde un clone du
Handle<SubMesh> de son support, et c’est le SubMesh qui possède les
increfs par nœud dans la Coords (cf.
Coords). Tant qu’une zone du champ est vivante,
son support l’est aussi, donc ses nœuds aussi — même si tous les Node
utilisateurs ont disparu.
La libération est automatique : quand le dernier Handle sur une zone
disparaît, la zone est détruite, son clone du handle de support avec elle, et
les nœuds redeviennent collectables si plus rien ne les retient (cf.
Modèle mémoire).
Opérateurs consommant un champ
Les opérateurs détaillés sont décrits dans
Opérateurs sur les champs (dérivations),
Assemblage (flux, second membre) et
Solveur. Tous consomment l’agrégat et résolvent les
nœuds à travers les zones (règle premier-trouvé) :
| Opération | Particularité multi-zones |
|---|---|
positions(mesh) | un SubNodeField par submesh, interfaces cohérentes par construction |
coords.set(f) / displace(f) | chaque nœud distinct traité une seule fois (un nœud d’interface n’est pas déplacé deux fois) |
gradient(f, fes) / deformation(u, fes) | lookups par nœud × Gauss via un snapshot des zones |
divergence(F) | adjoint de gradient : champ vectoriel par éléments → NodeField (div), accumulé par nœud (d_i = ∫ ∇N_i·F) |
solve(matrix, rhs) | second membre lu par DOF (absent ⇒ 0.0) ; solution : une zone par support colonne des blocs de la matrice, sur le handle même du bloc — same_support avec tout champ posé sur ces supports, stable d’une résolution à l’autre |
restrict(f, mesh) | une zone par submesh cible, sur le nuage POI1 canonique caché du sous-maillage (to_poi1) ⇒ deux restrictions sur le même mesh partagent le support et sont soustractibles (et s’alignent avec K·x/solve) ; 0.0 pour les nœuds non couverts |
restrict_like(f, target) | reprojette sur le support et les composantes de target (mêmes slots) ⇒ combinable par + - * / avec target ; nœuds/composantes hors de target abandonnés, 0.0 si non couverts |
merge(a, b) | union structurelle consolidée (conflit de valeur ⇒ erreur) |
node_field.consolidate(f) | fusion par jeu de composantes après vérification de cohérence |
Champ aux points de Gauss (ElementField / SubElementField)
Un champ aux points de Gauss porte une ou plusieurs valeurs par (cellule, point de Gauss) sur un espace éléments finis. Il suit la même
grammaire d’agrégat que tous les conteneurs de pyrucast (cf.
Agrégat) et le contrat de champ :
SubElementField— les valeurs d’une zone : un bloc multi-composantes sur les cellules × points de Gauss d’unSubFiniteElementSpace;ElementField— l’agrégat : une liste deSubElementField, un par sous-espace, avec éventuellement des composantes différentes d’une zone à l’autre.
C’est le miroir exact de NodeField côté points
d’intégration. C’est l’objet sur lequel s’écrivent naturellement :
- les propriétés matériau (module d’Young, Poisson, conductivité, masse volumique…) évaluées là où les intégrales sont calculées ;
- les variables internes (déformation plastique, endommagement…) gardées de cellule en cellule et de point en point ;
- les grandeurs dérivées d’une solution (contraintes, déformations, flux…)
pour le post-traitement, produites par les opérateurs de
champ (
gradient,deformation…) et le comportement (integrate_behavior).
Support : un sous-espace éléments finis par zone
Chaque SubElementField est attaché à un seul SubFiniteElementSpace (cf.
Espace éléments finis), qui détermine :
- la liste des cellules concernées (via son
SubMesh) ; - le nombre de points de Gauss par cellule (via sa
QuadratureRule).
Les trois dimensions d’une zone sont figées à la construction :
cell_count (du SubMesh), gauss_count (de la quadrature),
component_count (choisi par l’utilisateur). Le buffer interne est dimensionné
une fois pour toutes et n’est jamais réalloué — la topologie du maillage
sous-jacent doit rester figée pour la durée de vie du champ (les coordonnées,
elles, peuvent évoluer ; cf. FiniteElementSpace).
Les coordonnées et poids des points de Gauss ne sont pas stockés dans le
champ : ils restent sur le SubFiniteElementSpace comme données de référence.
ElementField (agrégat)
├── SubElementField zone 0 ── support SubFiniteElementSpace ── values[…]
├── SubElementField zone 1 ── support SubFiniteElementSpace ── values[…]
└── …
Composantes nommées
Chaque zone porte ses noms de composantes ("E", "nu", "sigma_xx",
"plastic_strain"…) : au moins une, noms uniques, valeurs initialisées à
0.0. Au niveau agrégat, components() renvoie l’union des composantes
des zones (ordre de première apparition) ; une composante peut n’exister que
sur certaines zones.
Disposition mémoire
Les valeurs d’une zone sont rangées à plat, ligne-major, dans l’ordre
cellule → gauss → composante :
values[cell_idx * gauss_count * component_count
+ g * component_count
+ c]
Cet ordre rend deux accès courants cache-friendly :
- lire toutes les composantes à un point de Gauss d’une cellule (par
exemple
(E, nu, rho)pendant l’assemblage) —component_countflottants contigus, exposés parpoint_values(cell, g); - balayer tous les points de Gauss d’une cellule pour une composante donnée
—
gauss_countflottants régulièrement espacés.
Construction : au niveau agrégat
Comme tout agrégat, un ElementField se construit au niveau parent, à
partir d’un FiniteElementSpace : une zone par sous-espace.
ElementField(fes, components)— la même liste de composantes pour chaque sous-espace ;ElementField.with_components_per_subspace(fes, [...])— une liste de composantes par sous-espace (multiphysique / multi-matériau).
Pour fabriquer un champ matériau prêt pour l’assemblage, l’opérateur
material_field(model, [...]) est plus direct : il
crée les zones nécessaires aux sous-modèles qui consomment du matériau et les
remplit en un appel.
Refcount et cycle de vie
Chaque zone détient un Handle<SubFiniteElementSpace> (cloné, donc compté).
Tant qu’une zone est vivante, son sous-espace ne peut pas être collecté ; à son
Drop, le refcount du sous-espace décroît et la cascade descend jusqu’au
SubMesh puis à la Coords. Un ElementField n’incrémente pas le
refcount des nœuds : il n’a pas de support nodal direct — les nœuds restent
protégés par le SubMesh du sous-espace.
API Rust
#[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(())
}
API Python
import pyrucast
# Maillage + FE space — préparation.
c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
c2 = c.add_node([0.0, 1.0])
mesh = pyrucast.Mesh(c, "TRI3")
mesh.unit().add_cell([a, b, c2])
fes = pyrucast.FiniteElementSpace(mesh)
# Material field: one zone per subspace of `fes`.
mat = pyrucast.ElementField(fes, ["E", "nu"])
print(mat) # ElementField: 1 subfield(s)
print(mat.unit()) # SubElementField: 1 cell(s) × 3 gauss × 2 component(s) [E, nu]
# Writing through the zone; reading through the zone (or the aggregate stats).
z = mat.unit() # the only zone (an error if there were several)
z.set_uniform("E", 210e9)
z.set_uniform("nu", 0.3)
assert z.value(0, 0, "E") == 210e9
# Dictionary-like access on the zone — `sub[cell, gauss, "name"]`.
z[0, 2, "nu"] = 0.28
assert z[0, 2, "nu"] == 0.28
# Stats and arithmetic at the aggregate level.
print(mat.min("E"), mat.max("E")) # 210000000000.0 210000000000.0
print(mat.sum("E")) # Σ over the Gauss points
mat.mul_to_component("E", 0.95) # en place, seulement "E"
scaled = mat * 1.1 # nouveau champ
# Components per subspace (multiphysics / multi-material).
ef = pyrucast.ElementField.with_components_per_subspace(fes, [["E", "nu"]])
print(ef.components()) # ['E', 'nu']
Le plus souvent, on ne construit pas le champ matériau à la main : on appelle
l’opérateur material_field qui apparie les zones aux
sous-modèles consommateurs de matériau et ignore les autres (Dirichlet, …).
Visualisation
element_field.plot(...) colore le champ sur son propre support : chaque
zone retrouve son sous-maillage via son sous-espace EF (partagé, pas copié). Le
rendu raisonne par élément — les valeurs nodales viennent d’un moindre
carré local à l’élément sur les valeurs de Gauss, sans moyenne inter-éléments,
de sorte que les discontinuités (flux, contraintes) restent visibles. Voir
Visualisation.
Sérialisation
SubElementField (et donc ElementField) implémente Portable via serde
comme tous les objets pyrucast : le buffer de valeurs et la liste de noms
voyagent dans le format binaire portable Linux ↔ Windows. Le lien vers l’espace
EF, lui, est un Handle : il devient un identifiant local au fichier, et
l’espace est écrit avec le champ — voir Sauvegarde et
relecture.
Limitations actuelles
- Pas de mécanisme de rééchantillonnage entre quadratures (« projeter ce champ Gauss-2-points sur un autre Gauss-3-points ») : le sous-espace est figé à la création de chaque zone.
- L’arithmétique binaire entre champs est stricte (même support, mêmes composantes ; cf. Champ) — il n’y a pas (encore) de combinaison tolérante avec rééchantillonnage ou complétion par zéro.
Modèle physique (Model)
Un Model est l’objet orchestrateur qui décrit un problème physique et produit les matrices (raideur, masse) à la demande de l’utilisateur. Il vient se poser sur la couche éléments finis (FiniteElementSpace), elle-même posée sur le maillage géométrique.
Géométrie Mesh / SubMesh (purement géométrique)
Formulation EF FiniteElementSpace / SubFiniteElementSpace (interpolation, quadrature)
Physique Model / SubModel / SubModelKind (loi, matériaux, assemblage)
Architecture
Model
├── sub_models: Vec<SubModel>
├── primal_vars(): Vec<String> # union — colonnes des matrices
├── dual_vars(): Vec<String> # union — lignes des matrices
├── filter(Physics) -> Model # sous-modèles d'une nature donnée
└── fespace() -> FiniteElementSpace # 1 sous-espace par sous-modèle de domaine
# (contraintes exclues, sans dédup)
ops::matrix (opérateurs, pas des méthodes de Model)
├── stiffness(model, materials) -> Matrix # K (assemblé sur demande)
└── mass(model) -> Matrix # M (assemblé sur demande)
SubModel (énum de stockage + dispatch — AUCUNE logique)
├── HeatConduction(HeatConduction) # chaque variante enveloppe une struct…
├── Dirichlet(Dirichlet) # …qui porte ses données + impl SubModelKind
├── Mpc(Mpc) # contrainte multi-points (relations linéaires)
├── fespace() -> Option<SubFiniteElementSpace> # sous-espace intégré (None si contrainte)
└── as_kind(&self) -> &dyn SubModelKind # l'unique match du module modèle
SubModelKind (trait de base — le dénominateur commun, co-localisé par physique)
├── primal_vars / dual_vars
├── physics # ensemble de natures : &[Physics] (Mechanical|Thermal|Constraint|Other)
├── as_domain / as_constraint # seams de capacité (None par défaut) — cf. ci-dessous
├── matrix_element # pont vers les noyaux élémentaires, qui vivent sur Domain
├── stiffness_layout # Some ⇒ bloc CALCULÉ (scatter parallèle) ; None ⇒ littéral
├── contributions # défaut : dérivé du layout ; contraintes rendent leurs C/Cᵀ littéraux
├── build_stiffness_blocks # défaut : stiffness_layout + Domain::element_matrix
├── build_mass_blocks # (défaut : vide)
└── label / display / render
Capacités (sous-traits, miroir des natures ; une struct n'a que la sienne) :
├── Domain # matériau + comportement (heat, elasticity, poutres, …)
└── Constraint # multiplicateurs de Lagrange (Dirichlet, MPC, embedded, contact)
L’énum SubModel ne sert qu’au stockage et à la sérialisation
(bincode) ; il délègue chaque appel à l’impl SubModelKind de la variante via
as_kind(). Tout le code générique (l’agrégat Model, l’assembleur,
Dump) passe par ce seul point — ajouter une physique ne touche donc aucun
de ces sites. Voir le chapitre Ajouter une physique.
Le Model est purement orchestrateur : il énumère les DOFs, dimensionne la Matrix, boucle sur les sub-models et accumule. Aucune logique physique ne vit chez lui.
Identification des DOFs : primal ≠ dual
Chaque physique déclare :
- ses variables primales (les inconnues — composantes du vecteur solution, colonnes de la matrice) ;
- ses variables duales (les conjuguées énergétiques — composantes du vecteur chargement, lignes de la matrice).
Elles sont presque toujours différentes :
| Physique | Primales (cols) | Duales (rows) |
|---|---|---|
HeatConduction | T (température) | q (flux de chaleur) |
BoundaryTransfer (film) | T (partagée avec HeatConduction) | q (partagée) |
Truss / LinearElasticity | u_x, u_y, … | f_x, f_y, … |
Dirichlet { imposed_variable: "T" } | lambda_T | imposed_T |
Les DOFs de la Matrix sont identifiés par le couple (NodeID, nom_de_champ) (voir Matrix) : deux SubModels qui utilisent le même nom ("T") sur des nœuds différents ne se collisionnent pas, et la jonction se fait automatiquement quand ils partagent un même (NodeID, nom).
Chargements complètement séparés du Model
Le Model ne porte aucune logique de second membre. L’utilisateur :
- lit
model.dual_vars()pour connaître les noms de composantes du vecteur force ; - construit un
NodeFieldavec ces composantes (forces de Neumann, sources de chaleur, valeurs imposées de Dirichlet aux nœuds-multiplicateurs, …) ; - compose plusieurs sources avec
|(union des zones, dédupliquée et fusionnée par support) — le nommémergeen est l’alias ; - passe
Matrix + NodeFieldau solveur.
Cette séparation a deux mérites :
- les chargements sont des données utilisateur, faciles à composer ;
- le
Modelreste une description compacte et indépendante du chargement (le même modèle peut être résolu avec plusieurs chargements en cascade).
Pour la part contrainte du second membre (les valeurs imposées aux
nœuds-multiplicateurs), le helper model.constraint_rhs([(nœud, g), …])
construit ce NodeField tout seul : on désigne chaque relation par un nœud
contraint (Dirichlet) ou un nœud-terme (MPC) et sa valeur g, et le helper
retrouve le nœud-multiplicateur et la composante à renseigner
(imposed_<v>, mpc_rhs). Voir Contraintes.
Les physiques disponibles
Chaque physique est une struct sous src/models/ implémentant le trait
SubModelKind, enveloppée par une variante de l’énum SubModel. Leur détail
(équations, matériau, comportement, exemples) est dans la partie
Détails des physiques ; on n’en rappelle ici que les
opérateurs qui les déclarent et leurs variables, vus du Model :
Opérateur (ops::model::…, Python pyrucast.model.…) | Primales | Duales | Matériau | Chapitre |
|---|---|---|---|---|
heat_conduction(fes) | T | q | k | Thermique |
heat_conduction_with_symmetry(fes, sym) | T | q | k_1… / k_11… + repère | Conduction orientée |
boundary_transfer(fes, cible, comps) | libres | libres | h_<primale>, a_ext_<primale> | Échanges |
radiation(fes, cible) | T | q | emis, T_inf (+ sigma facultatif) | Rayonnement |
fick(fes, espèce) | c_<espèce> | j_<espèce> | D_<espèce> ; poro facultatif | Diffusion |
fick_with_symmetry(fes, sym, espèce) | c_<espèce> | j_<espèce> | D_1_<espèce>… + repère ; poro facultatif | Diffusion |
interface_transfer(a, b, cible, comps, tol) | libres | libres | h_<primale> | Échanges |
truss(fes) | u_x, u_y(, u_z) | f_x, f_y(, f_z) | E, A | Barre |
elasticity(fes, model) | u_x, u_y(, u_z) | f_x, f_y(, f_z) | E, nu | Élasticité |
elasticity_with_symmetry(fes, model, sym) | u_x, u_y(, u_z) | f_x, f_y(, f_z) | E_1…G_23 / C_11…C_66 + repère | Orthotropie |
plasticity_perfect(fes, model) | u_x, u_y(, u_z) | f_x, f_y(, f_z) | E, nu, sigma_y | Plasticité |
plasticity_with_law(fes, model, law) | idem | idem | selon la loi | Lois d’écoulement, Fluage |
bernoulli(fes, model) | selon la configuration | idem | E, I (+ A, I_y…) | Euler-Bernoulli |
timoshenko(fes) | w, theta | f_w, m_theta | E, I, G, A_s | Timoshenko |
frame(fes) | u_x, u_y, rz | f_x, f_y, m_z | E, A, I, G, A_s | Portique 2D |
frame3d(fes) | u_x…r_z (6) | f_x…m_z (6) | E, A, I_y, I_z, J, G, A_sy, A_sz | Cadre 3D |
shell(fes, model)thick, kirchhoff | u_x…r_z (6) | f_x…m_z (6) | E, nu, h | Coques |
dirichlet(…) | lambda_<v> | imposed_<v> | — | Dirichlet |
mpc(…) | lambda_mpc | mpc_rhs | — | Multi-points |
embedded(…) | lambda_<v> | imposed_<v> | — | Baignage |
contact(…) | lambda_contact | contact_gap | — | Contact |
Toutes balaient tous les sous-espaces du fes (une zone par sous-espace),
sauf dirichlet, mpc, embedded et contact qui sont des
contraintes portées par des maillages fournis par
l’utilisateur. Le matériau est toujours fourni à l’assemblage, pas au
modèle (cf. ci-dessous).
Pour ajouter une physique, voir Ajouter une physique.
Ce que chaque physique calcule
Le tableau ci-dessus dit quelles variables porte chaque physique. Ce qu’elle sait produire — les genres de matrice qu’elle déclare, la voie par laquelle elle obtient sa tangente, son intégration de comportement et sa particularité de calcul — est rassemblé physique par physique dans Détails des physiques.
Nature physique et filtrage
Chaque physique déclare un ensemble de natures — sa classification grossière,
orthogonale à l’axe de capacité Domain/Constraint. Elle répond à « quel champ
de physique » là où les capacités répondent à « domaine ou contrainte » :
Nature (Physics) | Physiques |
|---|---|
Mechanical | truss, elasticity, plasticity, mazars, bernoulli, timoshenko, shell |
Thermal | heat_conduction, radiation ; boundary_transfer et interface_transfer quand leur cible est thermique |
Constraint | dirichlet, mpc, embedded, contact |
Other | nature « autre / rien » explicite (aucune physique de base ne la déclare) |
Diffusion | fick ; boundary_transfer et interface_transfer quand leur cible est une diffusion |
Radiation | radiation — portée en plus de Thermal, donc filter("thermal") le rend aussi |
Côté Python, les mêmes natures sont des chaînes : "mechanical", "thermal",
"constraint", "other", "diffusion", "radiation".
Diffusion est une nature à part entière bien que la loi de Fick partage
l’opérateur de la conduction : les variables diffèrent (c/j contre T/q),
et un problème couplé doit pouvoir sélectionner l’une sans l’autre. Partager un
opérateur n’est pas partager une physique.
La nature d’une physique de base est entièrement déterminée par la variante :
c’est une constante par physique, exposée par SubModelKind::physics() — un
slice &'static [Physics] (comme label()), pas un champ stocké. Le type est
un ensemble pour deux raisons :
- une physique couplée (par ex. un futur élément thermo-mécanique) porte
plusieurs natures —
[Mechanical, Thermal]; - un bloc de matrice monté à la main, hors assemblage, n’en porte aucune —
l’ensemble vide, le cas « rien ».
Physics::Otherest la nature « autre » explicite, pour un bloc qu’on veut classer plutôt que laisser sans étiquette.
L’ensemble voyage avec chaque bloc assemblé jusqu’à la SubMatrix
(posé par l’assembleur sur les deux chemins, calculé et littéral, donc le couple
C/Cᵀ d’un Dirichlet est étiqueté aussi).
Deux sélecteurs symétriques en découlent — ils gardent les entités dont l’ensemble contient la nature (une physique couplée apparaît donc sous chacune) — tous deux à partage par compteur de références (pas de copie profonde) :
model.filter(Physics::Mechanical)→ unModelne gardant que les sous-modèles au moins mécaniques ;k.filter(Physics::Mechanical)→ uneMatrixne gardant que les blocs au moins mécaniques (non assemblée — relancerMatrix::assembleavant de résoudre).
k.physics() renvoie l’ensemble des natures présentes dans la matrice
(dédupliqué) : une matrice agrégeant plusieurs physiques y expose plusieurs tags
(par ex. [Thermal, Constraint]). Un bloc à l’ensemble vide n’est jamais
sélectionné par une nature concrète — l’étiqueter Physics::Other le rend
atteignable par filter(Physics::Other).
#[test]
fn filtrer_un_modele_et_sa_matrice_par_nature() -> 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 mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
mesh.add_cell(&[a.id(), b.id()])?;
let fes = FiniteElementSpace::lagrange1(&mesh)?;
let model = model::heat_conduction(&fes)?;
let materials = element_field::material_field(&model, &[("k", 1.0)])?;
let k = matrix::stiffness(&model, &materials)?;
let meca = model.filter(Physics::Mechanical); // sous-modèles au moins mécaniques
let k_meca = k.filter(Physics::Mechanical); // blocs au moins mécaniques (non assemblés)
let natures = k.physics(); // ex. [Thermal, Constraint]
assert!(meca.is_empty() && k_meca.is_empty()); // ce modèle est thermique
assert!(natures.contains(&Physics::Thermal));
Ok(())
}
Règle invariante : un Model = une Matrice
matrix::stiffness(model, materials) et matrix::mass(model, materials) produisent chacune une seule Matrix couvrant l’ensemble des DOFs du Model (primaux ⊕ multiplicateurs). Les conditions limites n’ont pas de statut spécial — ce sont des sub-models comme les autres qui contribuent leurs entrées dans la même matrice globale.
Cette uniformité simplifie tout : le solveur reçoit une seule Matrix + un seul NodeField ; pas besoin de jongler avec un système saddle-point composé.
API Rust
#[test]
fn un_modele_se_declare_et_s_assemble() -> Result<()> {
// 1-D: a [0, 1] mesh with a single 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 mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
mesh.add_cell(&[a.id(), b.id()])?;
let fes = FiniteElementSpace::lagrange1(&mesh)?;
// Model: conduction (the material is supplied at assembly, not here) +
// Dirichlet on the left. Constructors at the parent level (they sweep
// `fes`'s subspaces), composed with `union` — a `SubModel` is never built by
// hand (see CONVENTIONS.md).
let hc = model::heat_conduction(&fes)?;
// Mesh of the imposed nodes + support of the multipliers (barycenter
// co-locates fresh nodes). The model creates no node itself.
let imposed = mesh::poi1_from_nodes(std::slice::from_ref(&a))?;
let multiplier = mesh::barycenter(&imposed)?;
let dir = model::dirichlet(&hc, "T", &imposed, &multiplier, RelationSense::Equality)?;
let model = hc.union(&dir)?;
// Material k = 1, applied to the sub-models that need it (Dirichlet is
// skipped automatically), then assembly.
let materials = element_field::material_field(&model, &[("k", 1.0)])?;
let k = matrix::stiffness(&model, &materials)?;
assert_eq!(k.n_rows()?, 3); // 2 nœuds physiques + 1 multiplicateur
Ok(())
}
API Python
import pyrucast
c = pyrucast.Coords(dim=1)
a = c.add_node([0.0])
b = c.add_node([1.0])
mesh = pyrucast.Mesh(c, "SEG2")
mesh.unit().add_cell([a, b])
fes = pyrucast.FiniteElementSpace(mesh)
# Model: conduction (material supplied at assembly) + Dirichlet on the left.
# Constructors at the parent level, composed with `|` — no SubModel by hand.
# The multipliers' mesh is built from the imposed nodes.
imposed = pyrucast.mesh.poi1_from_nodes([a])
multiplier = pyrucast.mesh.barycenter(imposed)
cible = pyrucast.model.heat_conduction(fes)
model = cible | pyrucast.model.dirichlet(cible, "T", imposed, multiplier)
# Material k = 1 (the Dirichlet sub-models are skipped automatically).
materials = pyrucast.element_field.material_field(model, [("k", 1.0)])
K = pyrucast.matrix.stiffness(model, materials)
print("primal_vars =", model.primal_vars()) # ['T', 'lambda_T']
print("dual_vars =", model.dual_vars()) # ['q', 'imposed_T']
print(K) # Matrix: 3 row(s) × 3 col(s), …
Assemblage et résolution
L’assemblage (stiffness / mass) et la résolution (solve) sont des
opérateurs : ils consomment le Model (et le matériau, le chargement) et
sont décrits dans la partie Détail des opérateurs —
Assemblage et Solveur. Le
solveur est une LU creuse directe (faer), dont la factorisation est mise en
cache sur la Matrix — factoriser une fois, résoudre souvent.
Des exemples complets et à solution analytique (assemblage + contraintes + lecture des inconnues et des réactions) sont déroulés dans Dirichlet (Poisson 1-D), Conduction thermique et Mécanique.
Limitations actuelles
- Physiques disponibles :
HeatConductionetBoundaryTransfer(échange de surface / film) (thermique) ;Truss,Elasticity,Plasticity,Mazars,Timoshenko,Frame,Frame3d(mécanique) ; et les contraintesDirichlet,Mpc,Embedded,Contact(contraintes). Toute nouvelle physique est une struct implémentantSubModelKind(une variante de l’énumSubModel- un bras de
as_kind, rien d’autre — cf. Ajouter une physique). Le coût d’ajout est O(1) fichier, indépendant du nombre de physiques existantes.
- un bras de
- Toutes les physiques n’ont pas tous les genres de matrice : chacune
déclare les
MatrixKindqu’elle sait produire (raideur, masse, raideur géométrique, tangente cohérente). Assembler un genre qu’une physique n’a pas ne casse rien — elle ne contribue simplement pas. - Pas de check de cohérence pré-assemblage : la consistance (matériau
définit bien
"k"pour HeatConduction, compatibilité des FE spaces entre sub-models, etc.) est vérifiée au moment dematrix.stiffness/matrix.mass, pas à l’ajout du sub-model. Si on découvre des cas où ça pose problème, un check eager est facile à ajouter. - Deux back-ends de solveur, tous deux directs : LU creuse (défaut) et
Cholesky, au choix de l’appelant via
method=, toutes deux en faer et avec cache de factorisation. Cholesky exige une matrice qui se déclare symétrique — un triangle ne se lisant que d’un côté, elle ne se tromperait pas bruyamment sur une matrice qui ne l’est pas — et définie positive : un point-selle à multiplicateurs, symétrique mais indéfini, est refusé au pivot fautif, qu’on l’élimine d’abord ou qu’on le résolve en LU. Pas de méthode itérative ;SolveMethodreste le point d’extension prévu pour cela.
Matrice creuse (Matrix)
Matrix est le conteneur de sortie d’un assemblage : c’est ce que produisent les opérateurs matrix::stiffness(model, materials) / matrix::mass(model, materials) à partir d’un Model. Elle représente une matrice creuse dont les lignes et les colonnes sont identifiées par des DOFs nommés.
Identification des DOFs : (NodeId, nom_de_champ)
Chaque ligne et chaque colonne d’une Matrix est identifiée par un couple (NodeId, ChampId) :
NodeId— l’identifiant stable d’un nœud dans laCoords.ChampId— un indice compact dans une petite table de noms portée par la matrice (typiquement 5–10 entrées). Les noms sont des chaînes comme"T","q","ux","lambda_w".
Le type concret est DofId { node_id, field_idx }. Cette représentation est compacte (un u32 par champ partagé sur tous les DOFs qui le portent) et conserve l’information sémantique : à chaque entrée numérique de la matrice est attaché « quel inconnu, à quel nœud ».
Les jeux de DOFs de lignes et de colonnes sont indépendants :
- ils peuvent avoir des tailles différentes (matrice rectangulaire — par exemple le bloc Lagrange d’une condition de Dirichlet) ;
- ils peuvent porter des noms de champs différents (les lignes étiquetées par des duales
q, les colonnes par des primalesT).
Blocs bi-mode : littéral ou calculé
Une Matrix est un agrégat de blocs SubMatrix, et un bloc est de l’un de deux modes :
- littéral — il porte ses valeurs, stockées en COO (coordinate triplet list). Chaque
add_entry(...)ajoute un triplet(ligne, colonne, valeur); plusieurs entrées au même couple s’accumulent (sommées à l’assemblage), l’ordre d’insertion étant sans effet. C’est le mode historique — celui des contraintes (blocsC/Cᵀde Dirichlet) et de tout bloc monté à la main. - calculé — il ne porte aucune valeur, seulement une recette
{ sous-modèle, sous-espace EF, matériau }. Ses entrées sont produites à l’assemblage par le noyau élémentaire du sous-modèle, dispersées directement dans la matrice globale. C’est le mode des physiques volumiques (raideur), qui évite de matérialiser un COO intermédiaire.
Un bloc calculé garde son lien vers sa physique via la recette ; la Matrix, elle, reste un simple sac de blocs et ne référence pas le Model.
Le bloc ne recopie pas sa liste de nœuds
Un bloc est posé sur deux supports POI1 (lignes et colonnes, souvent le même objet) et n’en garde aucune copie : il lit leur connectivité en place à chaque accès, conformément à la règle Zéro-copie. C’est sûr parce que les deux supports sont scellés à la construction du bloc — leur connectivité ne peut plus changer, donc la numérotation ne peut pas dériver. Le NodeId → position passe par la table que le support porte déjà (SubMesh::node_index), partagée avec tous ses autres consommateurs au lieu d’être refaite par bloc.
Corollaire à connaître si l’on monte un bloc à la main : les nœuds d’un support doivent être distincts, ce que produit to_poi1. Un nœud répété n’est pas rejeté, mais il adresse la mauvaise ligne — la table du support donne un rang dédoublonné, qui s’écarte de la position dès la première répétition.
Étiquette de nature physique (physics)
Chaque bloc porte en plus un ensemble de natures Vec<Physics> (Mechanical,
Thermal, Constraint, Other) — l’assembleur le pose sur tout bloc qu’il
émet, sur les deux chemins (calculé et littéral), donc le couple C/Cᵀ d’un
Dirichlet est étiqueté lui aussi. C’est ce qui rend l’étiquette utilisable là où la
recette manque (blocs littéraux). Le tag est un ensemble : vide pour un bloc
monté à la main hors assemblage (le cas « rien »), et à plusieurs éléments pour
une physique couplée.
Il alimente Matrix::filter(Physics) — le miroir de
Model::filter — qui renvoie une Matrix
ne gardant que les blocs dont l’ensemble contient la nature donnée (handles
partagés, pas de copie). Le résultat n’est pas assemblé : relancer
Matrix::assemble avant de résoudre. Un bloc à l’ensemble vide n’est
sélectionné par aucune nature concrète ; l’étiqueter Physics::Other le rend
atteignable. Matrix::physics() renvoie l’ensemble des natures présentes dans la
matrice (dédupliqué — « plusieurs tags » au niveau de l’agrégat).
#[test]
fn filtrer_une_matrice_par_nature() -> Result<()> {
let (model, materials, _, _) = barre()?;
let k = matrix::stiffness(&model, &materials)?;
let k_meca = k.filter(Physics::Mechanical); // blocs au moins mécaniques
let natures = k.physics(); // ex. [Thermal, Constraint]
assert!(k_meca.is_empty()); // ce modèle est thermique
assert!(natures.contains(&Physics::Thermal));
Ok(())
}
Assemblage : motif + scatter
Passer d’un agrégat de blocs à une matrice utilisable se fait en deux temps :
- Motif creux (sparsité CSR) — l’union dédoublée des DDL des blocs (une liste globale, via table de hachage) et de leurs entrées. Il ne dépend que de la topologie (bloc calculé via la connectivité, bloc littéral via sa COO), pas des matériaux ;
stiffnessle mémoïse donc sur leModelet le réutilise d’un assemblage à l’autre. - Valeurs — dispersées (scatter) dans le CSR : un bloc calculé lance son noyau élémentaire (en parallèle, par coloration des cellules — voir Parallélisme) ; un bloc littéral recopie sa COO. Chaque bloc remappe sa numérotation locale
(nœud, variable)vers l’index global via une table de traduction — O(nnz), sans recherche par entrée (NodeIdest déjà l’index nœud global dense, etadd_entryretrouve la position d’un nœud en O(1)).
L’ordre des DOFs dans row_dofs() / col_dofs() est l’ordre de première rencontre des blocs — sauf si le Coords porte une permutation (ordre solveur), auquel cas la liste globale suit cet ordre (tri stable). Reproductible dans les deux cas.
finalize vs ops::matrix
Matrix::finalize()n’assemble que des blocs littéraux (somme des COO → CSR). Il refuse un bloc calculé : le noyau vit dansmodels, hors decontainers, et l’y appeler créerait un cyclematrix ↔ kernel. Il renvoie alors versops::matrix.ops::matrix::stiffness(model, materials)construit les blocs (calculés pour les physiques volumiques, littéraux pour Dirichlet) et assemble, motif mémoïsé sur leModel.Matrix::assemble(&mut self)réassemble une matrice depuis ses blocs seuls, sansModel: c’est le chemin de composition — combiner une sous-matrice neuve (de provenance quelconque) à une matrice existante puis réassembler. LaMatrixne dépendant que de ses blocs, cette composabilité de base est ainsi préservée y compris en présence de blocs calculés.
Pour qui veut une matrice creuse d’une autre bibliothèque, des conversions à la demande existent — elles fabriquent un objet neuf et ne retiennent rien :
Matrix::to_csr→nalgebra_sparse::CsrMatrix<f64>Matrix::to_csc→nalgebra_sparse::CscMatrix<f64>Matrix::to_coo→nalgebra_sparse::CooMatrix<f64>Matrix::to_dmatrix→nalgebra::DMatrix<f64>
Mais le solveur n’en emprunte aucune. La forme assemblée n’a pas besoin d’être
convertie, seulement d’être regardée sous le bon angle : une CSR est, octet pour
octet, la CSC de la transposée, et une CSC triée sans doublon est exactement ce que
le LU creux de faer demande. ops::solver::lu lit donc les tableaux par
Matrix::csr_arrays — empruntés, la sparsité
restant celle du motif — transpose une fois par tri par comptage, et tend à faer
une vue. Aucune copie de la matrice ne coexiste avec la factorisation, qui est
le moment où la mémoire est la plus tendue.
Le produit matrice-vecteur, lui, tire parti de l’orientation lignes : lectures contiguës, un accumulateur par ligne, parallélisable sur les lignes sans atomique. C’est la raison pour laquelle la forme assemblée reste une CSR.
Facteur scalaire et somme de matrices
Le facteur
Chaque SubMatrix porte un facteur f64, 1.0 par défaut, ajusté par
bloc * s, bloc / s et -bloc. Le facteur ne touche que ce champ — jamais les
valeurs stockées (coo) — ce qui le rend utilisable aussi bien sur un bloc littéral
que sur un bloc calculé (dont les valeurs n’existent qu’à l’assemblage, produites
par le noyau élémentaire). Il est pris en compte partout où une valeur du bloc est lue
ou émise : les accesseurs directs (get, dense, to_dmatrix, to_coo, to_csr,
to_csc, mul_dense) et les deux passes d’assemblage global (Matrix::finalize et
ops::matrix::scatter, calculé comme littéral). Seules les formes locales brutes
(local_triplets, local_coo_arrays) restent non mises à l’échelle — ce sont des vues
internes destinées au remappage global, chaque consommateur y applique le facteur
lui-même.
&Matrix * s, &Matrix / s et -&Matrix mettent à l’échelle une matrice entière :
chaque bloc est cloné dans un nouvel objet avec son facteur ajusté. C’est nécessaire
car add_sub/union/filter/subset partagent les Handle<SubMatrix> (même
objet, compté) plutôt que de les copier ; muter le facteur en place rescalerait
silencieusement toute autre Matrix référençant le même bloc. La forme possédante
(matrix * s sur une valeur, pas une référence) fait l’économie de cette copie pour
tout bloc que personne d’autre ne tient — ce que Handle::is_sole_owner établit.
Le scalaire se lit des deux côtés (2.0 * k comme k * 2.0). Ces opérateurs sont
infaillibles, à une exception près : une division refuse un diviseur nul ou non
fini, qui rendrait non finie chaque valeur du résultat. Côté Rust elle interrompt
l’exécution ; côté Python elle lève ZeroDivisionError ou ValueError.
La CSR assemblée suit, mise à l’échelle. Mettre tous les blocs à la même échelle met
chaque entrée à cette échelle et laisse la sparsité intacte : seul le tableau des
valeurs est parcouru, les tableaux d’indices et la table de noms sont des Arc partagés.
Une matrice assemblée reste donc assemblée après * s, et n’a pas à repasser par un
assemble() qui relancerait tous les noyaux élémentaires pour appliquer un scalaire.
(Σ v) · sn’est pas, au bit près, leΣ (v · s)que calculerait un réassemblage : l’addition flottante n’est pas associative. Les deux valent la même quantité à l’arrondi près, et chaque chemin reste reproductible.
#[test]
fn diviser_une_matrice_ne_reecrit_aucune_valeur() -> Result<()> {
let (model, materials, _, _) = barre()?;
let m = matrix::stiffness(&model, &materials)?;
let a = m.row_mesh()?.node(0, 0, 0)?.id();
let dt = 0.1;
// Facteur 1/dt sur chaque bloc : aucune valeur stockée n'est réécrite. Et
// `m` étant déjà assemblée, sa CSR suit, mise à l'échelle — donc pas de
// réassemblage, donc aucun noyau élémentaire relancé pour un scalaire.
let m_dt = &m / dt;
assert_eq!(m.get(a, "q", a, "T"), m_dt.get(a, "q", a, "T") * dt); // m inchangée
assert!(m_dt.to_csr().is_ok()); // utilisable telle quelle
Ok(())
}
La somme
a + b rend une Matrix portant les blocs des deux opérandes, partagés et
délibérément non dédoublonnés. Rien n’est calculé : c’est l’assembleur qui somme ce
qui retombe sur le même (row, col) global (build_global_triplets,
scatter_serial/scatter_parallel). Une somme coûte donc quelques incréments de
compteur, ne touche aucune valeur, et laisse un bloc calculé calculé. Comme pour
filter, le résultat n’est pas assemblé : assemble() avant de résoudre.
a - b nie les blocs de droite, ce qui les recopie (le facteur vit dans le bloc) ;
a + b ne copie rien. Les deux opérateurs acceptent indifféremment une Matrix ou une
SubMatrix de chaque côté, et -a nie une matrice entière.
#[test]
fn additionner_deux_matrices_puis_resoudre() -> Result<()> {
let (model, materials, _, rhs) = barre()?;
let k = matrix::stiffness(&model, &materials)?;
let m = matrix::stiffness(&model, &materials)?;
let dt = 0.1;
// `+` porte les blocs des deux opérandes, partagés : rien n'est copié,
// aucune valeur n'est touchée. C'est l'assembleur qui somme ce qui retombe
// au même (ligne, colonne) global.
let mut sys = &m / dt + &k;
sys.assemble()?; // la somme n'est pas assemblée : requis avant de résoudre
let u = solver::lu::solve(&sys, &rhs)?;
assert!(u.node_count()? > 0);
Ok(())
}
Aucun traitement particulier n’est nécessaire quand K et M n’ont pas le même
ensemble de DOFs (cas courant : un Dirichlet/MPC n’entre que dans la matrice de
raideur, jamais dans la masse) — la somme prend simplement l’union des DOFs des deux
côtés, et les blocs de M ne contribuent rien aux DOFs qu’ils ne portent pas.
| compose, + additionne
C’est la seule chose qui les sépare, et elle ne se voit que sur des blocs partagés :
l’union écarte un bloc dont elle tient déjà l’emplacement, la somme le compte à
chaque fois qu’on le lui donne. Donc k | k vaut k, tandis que k + k vaut 2k.
Prendre | pour composer un opérateur à partir de morceaux distincts (une raideur
et son bloc de Dirichlet), + pour additionner deux opérateurs.
#[test]
fn union_compose_somme_additionne() -> Result<()> {
let (model, materials, _, _) = barre()?;
let k = matrix::stiffness(&model, &materials)?;
let a = k.row_mesh()?.node(0, 0, 0)?.id();
let kaa = k.get(a, "q", a, "T");
// `+` compte une contribution chaque fois qu'on la lui donne.
let mut deux_fois = &k + &k;
deux_fois.assemble()?;
assert_eq!(deux_fois.get(a, "q", a, "T"), 2.0 * kaa);
// `|` écarte un bloc dont il tient déjà l'emplacement : c'est l'outil pour
// composer un opérateur à partir de morceaux distincts, pas pour additionner.
let mut une_fois = k.union(&k)?;
une_fois.assemble()?;
assert_eq!(une_fois.get(a, "q", a, "T"), kaa);
// `-` nie les blocs de droite (ce qui les recopie) ; `+` ne copie rien.
let mut nulle = &k - &k;
nulle.assemble()?;
assert_eq!(nulle.get(a, "q", a, "T"), 0.0);
Ok(())
}
Symétrie
Le dernier argument des constructeurs de SubMatrix déclare quelle part de la
symétrie de la matrice ce bloc porte :
Symmetry | sens |
|---|---|
Full | le bloc est symétrique à lui seul — toute raideur de Galerkine, toute matrice de masse, toute matrice de Gram |
Half(id) | il n’en porte que la moitié : sa transposée est l’autre bloc de même identité. Ni l’un ni l’autre n’est symétrique seul |
None | il n’en porte aucune |
La propriété visée est celle du tableau assemblé — A[i][j] == A[j][i] sur la
CSR — et rien d’autre. Un bloc la déclare et on le croit : un modèle sait ce
qu’il écrit, et rien ici ne le vérifie. Déclarer juste est donc tout le travail du
producteur, et l’agrégat additionne les déclarations sans les corriger. Un bloc
vide, par exemple, est symétrique : il déclare Full, et la règle n’a pas
d’exception à prévoir pour lui.
La numérotation suit la déclaration
Un tableau n’est symétrique que si le rang i désigne des DDL conjugués des
deux côtés. Or les deux ordres globaux se construisaient par deux parcours
indépendants des blocs, et rien ne les faisait tomber d’accord : une contrainte
qui introduit deux nœuds neufs d’un coup — embedded, dont le nœud immergé
n’appartient à aucune physique — les faisait découvrir en ordre inverse de chaque
côté.
Quand la matrice se déclare symétrique, les deux ordres sont donc construits en
un seul parcours conjugué. Ce parcours n’apprend jamais que q est le dual
de T : il lit seulement quelles positions se correspondent, ce que la
déclaration dit déjà. Un bloc Full est carré sur un support unique, donc sa
ligne k fait face à sa propre colonne k ; une paire Half croise, la ligne
de l’un faisant face à la colonne de l’autre.
Deux incohérences y sont refusées, jamais rattrapées : un même DDL dual déclaré conjugué à deux DDL primaux différents, et une paire dont les deux membres n’ont pas le même nombre de DDL.
Une matrice non symétrique garde les deux parcours indépendants : une matrice rectangulaire n’a pas de conjugué à apparier.
Pourquoi une moitié
Une contrainte de Dirichlet introduit deux blocs rectangulaires, C et Cᵀ
(voir Contraintes). Aucun des deux ne peut être symétrique — un
bloc rectangulaire ne l’est jamais — mais ensemble ils le sont. C’est une propriété
du couple, que Half rend exprimable : le producteur qui écrit le même
coefficient des deux côtés est celui qui les apparie.
L’identité de la paire est une empreinte de son contenu : les nœuds des deux supports, les quatre noms de variables, le coefficient. Déterministe, donc l’archive reste reproductible ; et si deux paires réellement distinctes venaient à partager une empreinte, elles seraient rejetées, jamais acceptées à tort — on oublie une symétrie, on n’en invente pas.
Ce que l’agrégat en conclut
Matrix::symmetric() est vrai si chaque bloc est Full, ou Half avec ses deux
membres présents en nombres égaux. Compter les membres plutôt que les blocs permet
à une contrainte déclarée deux fois (quatre blocs, deux de chaque) de tenir, tandis
qu’une paire coupée par un subset tombe.
Le stockage n’est pas dédupliqué : une matrice symétrique porte quand même ses
deux triangles. Mais la déclaration, elle, est consultée : elle décide si la CSR
assemblée peut être tendue telle quelle à la factorisation comme sa propre CSC. Une
matrice symétrique l’est — CSR(A) est au bit près CSC(Aᵀ), et Aᵀ = A — si bien
que le retournement, et la seconde copie complète de la matrice qui va avec,
disparaissent. C’est elle aussi qui autorise Cholesky (voir
Résolution).
Cas d’usage typique : matrice de raideur du laplacien
#[test]
fn les_entrees_vivent_dans_un_bloc() -> Result<()> {
// The entries live in a **block**, never in the aggregate: a block knows its
// POI1 supports (rows and columns) and its variable names.
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 block = SubMatrix::new(
support.clone(), // support des lignes
support.clone(), // support des colonnes (carré ici)
vec!["q".into()], // variables duales → lignes
vec!["T".into()], // variables primales → colonnes
DofOrdering::NodesThenVars,
Symmetry::Full, // symétrique à lui seul
);
// A simple 2-node model (a segment):
// K = [[ 2, -1], [-1, 2]]
block.add_entry(a.id(), "q", a.id(), "T", 2.0)?;
block.add_entry(a.id(), "q", b.id(), "T", -1.0)?;
block.add_entry(b.id(), "q", a.id(), "T", -1.0)?;
block.add_entry(b.id(), "q", b.id(), "T", 2.0)?;
let mut k = Matrix::empty();
k.add_sub(Handle::new(block))?;
k.finalize()?; // requis avant tout usage solveur
assert_eq!(k.n_rows()?, 2);
assert_eq!(k.n_cols()?, 2);
assert!(k.symmetric());
Ok(())
}
Matrice rectangulaire : bloc Lagrange
Une contrainte de Dirichlet introduit, par sa nature, un bloc rectangulaire : lignes indexées par les nœuds-multiplicateurs (un par contrainte), colonnes par les nœuds primaires contraints.
#[test]
fn un_bloc_de_lagrange_est_rectangulaire() -> 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)
};
// 2 constraints: the multipliers m0/m1 tie the primary nodes a/b.
// The block is rectangular as soon as the two supports differ — here they
// have the same size, but they are two distinct node clouds.
let m0 = Node::create_in(coords.clone(), &[0.0])?;
let m1 = Node::create_in(coords.clone(), &[1.0])?;
let mult_support = {
let mut sm = SubMesh::new(coords.clone(), ElementType::POI1);
sm.add_cell(&[m0.id()])?;
sm.add_cell(&[m1.id()])?;
Handle::new(sm)
};
let mut block = SubMatrix::new(
mult_support,
support.clone(),
vec!["T".into()],
vec!["T".into()],
DofOrdering::NodesThenVars,
// Un bloc rectangulaire ne peut pas être symétrique seul ; construit à
// la main, il n'a pas de moitié qui lui réponde.
Symmetry::None,
);
block.add_entry(m0.id(), "T", a.id(), "T", 1.0)?;
block.add_entry(m1.id(), "T", b.id(), "T", 1.0)?;
let mut c = Matrix::empty();
c.add_sub(Handle::new(block))?;
c.finalize()?;
assert_eq!(c.n_rows()?, 2);
assert_eq!(c.n_cols()?, 2);
// "T" is interned once only in the name table even though it appears on the
// row side AND the column side (the collision is settled by the distinct
// `NodeId`: the multipliers are nodes in their own right).
assert_eq!(c.field_names().len(), 1);
Ok(())
}
API Rust — accès en lecture
#[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()])?;
// Toutes ces lectures traversent l'état assemblé : elles rendent un
// `Result` and fail until `finalize()` (or `assemble()`) has
// été appelé.
// Value at a coordinate (the sum of every COO entry at that point).
let v: f64 = k.get(a, "q", a, "T");
// Dense row-major view (a flat Vec, handy for Python).
let d: Vec<f64> = k.dense()?;
assert_eq!(d.len(), k.n_rows()? * k.n_cols()?);
// Typed nalgebra dense view (column-major DMatrix), ready for LU/Cholesky.
let m: nalgebra::DMatrix<f64> = k.to_dmatrix()?;
// nalgebra-sparse sparse views, ready for the sparse solvers. `to_csr`
// materializes; `csr_arrays` borrows the three arrays without copying.
let csr: nalgebra_sparse::CsrMatrix<f64> = k.to_csr()?;
let csc: nalgebra_sparse::CscMatrix<f64> = k.to_csc()?;
let (offsets, cols, vals): (&[usize], &[usize], &[f64]) = k.csr_arrays()?;
assert_eq!(offsets.len(), k.n_rows()? + 1);
assert_eq!(cols.len(), vals.len());
// Iteration over the raw triplets (insertion order preserved). An entry is a
// 5-tuple `(row node, dual var, column node, primal var, value)` — the
// variable names are already resolved there.
for (row_node, row_var, col_node, col_var, value) in k.iter_entries() {
let _ = (row_node, row_var, col_node, col_var, value);
}
// Matrix · field product: `x` is read at the *column* DOFs (**primal** vars),
// the result is a `NodeField` on the *row* DOFs (**dual** vars) — `K · u = f`.
// The `*` operator is its sugar.
let y: NodeField = k.mul_field(&x)?;
let y_sucre: NodeField = (&k * &x)?; // le même produit, en opérateur
let _ = (v, m, csr, csc, y, y_sucre);
Ok(())
}
API Python
import pyrucast
# The entries live in a **block**, never in the aggregate: a block knows its
# POI1 supports (rows and columns) and its variables.
k = pyrucast.Matrix.block(support, support, ["q"], ["T"], symmetry="full")
bloc = k[0]
bloc.add_entry(a, "q", a, "T", 2.0)
bloc.add_entry(a, "q", b, "T", -1.0)
bloc.add_entry(b, "q", a, "T", -1.0)
bloc.add_entry(b, "q", b, "T", 2.0)
k.finalize() # required before any solver use
assert k.n_rows() == 2
assert k.n_cols() == 2
assert k.symmetric is True
# What it weighs, estimated: the four entries of the block, plus the assembled
# CSR. The factorization, larger than both, cannot be counted (faer keeps the
# size of its factors private).
assert bloc.memory_bytes() == 4 * 24
assert k.memory_bytes() > bloc.memory_bytes()
Ce que pèse une matrice
memory_bytes(), sur un bloc comme sur la matrice, estime les octets de tas
occupés. Pour un bloc, ce sont ses entrées stockées : un indice de ligne, un
indice de colonne et une valeur, soit 24 octets chacune — un bloc calculé n’en
stocke aucune et répond 0. Pour la matrice, c’est la CSR assemblée (un indice
de colonne et une valeur par terme non nul, un décalage par ligne, une clé de
DDL par ligne et par colonne) plus ce que gardent ses blocs.
L’estimation apparaît dans l’affichage : Matrix: 2 sous-matrice(s), 3 row(s) × 3 col(s), symmetric, ~1.2 kB.
Ce qu’elle ne compte pas, et qui est pourtant le plus gros : la factorisation. Ses facteurs pèsent vingt à soixante-cinq fois la matrice, mais faer garde leur taille privée. Pour la mémoire réellement consommée par un solve, voir Calculs plus gros que la RAM.
Sérialisation
Matrix implémente Portable via serde (comme tous les objets pyrucast). Les triplets COO, la table de noms et les DOFs voyagent dans le format binaire portable Linux ↔ Windows. La CSR assemblée et la factorisation, elles, ne sont pas écrites : elles se reconstruisent (voir Sauvegarde et relecture).
Limitations actuelles
- Cache de motif non invalidé par les mutations profondes : le motif creux mémoïsé sur le
Modelest invalidé à l’ajout d’un sous-modèle (add_sub), mais pas si le maillage / l’espace EF sous-jacent change en place (remaillage) — reconstruire le modèle dans ce cas. Le chemin de compositionm.assemble(), lui, reconstruit toujours le motif depuis les blocs. - Pas de produit matrice-matrice : à venir avec les premiers besoins concrets (préconditionneurs, formulations couplées).
- La somme n’assemble pas de manière opportuniste :
a + brend une matrice non assemblée même quand les deux opérandes le sont. Fusionner leurs CSR — ce qui éviterait de relancer les noyaux élémentaires dans une boucle en temps à pas variable — est possible sans changer la sémantique (l’ordre des DDL d’une concaténation est exactement celui deasuivi des DDL que seulebapporte), mais demande une addition creuse complète : retable des variables, remappage et retri des colonnes deb, fusion ligne à ligne. À faire quand un intégrateur en temps le justifiera. - La symétrie déclarée n’est pas vérifiée numériquement à l’assemblage, et ne doit pas l’être : c’est une déclaration du modèle, pas une mesure. Des tests unitaires confrontent la déclaration à la CSR réellement assemblée ; le calcul, lui, fait confiance.
- Une symétrie découpée en tranches de lignes n’est pas exprimable : deux blocs rectangulaires qui sont chacun une tranche de lignes d’une matrice symétrique ne peuvent rien déclarer — ni
Full, qui suppose un bloc carré, niHalf, les deux n’étant pas transposés l’un de l’autre. Le tableau assemblé est symétrique, le drapeau répondfalse, et le calcul prend le chemin général. C’est le mode de défaillance voulu : on oublie une symétrie, on n’en invente pas.
Évolution (Evolution / SubEvolution)
Une évolution associe une série de valeurs à une variable (souvent
le temps, mais pas nécessairement) et interpole linéairement entre les
échantillons tabulés. C’est l’analogue de l’EVOLUTIO de Cast3M, généralisé :
l’abscisse n’est pas forcément le temps et la valeur peut être un champ
entier, pas seulement un réel.
Elle suit la même grammaire d’agrégat que tous les conteneurs de pyrucast (cf. Agrégat) :
SubEvolution— une courbe tabulée : une liste d’abscisses (triées, strictement croissantes) et la liste des valeurs en regard. Une valeur est un scalaire, unSubNodeField(champ aux nœuds) ou unSubElementField(champ aux points de Gauss) — toutes du même type, et pour les champs sur le même support. Son interpolation enxrend une valeur.Evolution— l’agrégat : une liste deSubEvolution, une par zone, exactement comme unNodeFieldagrège desSubNodeField. Son interpolation enxinterpole chaque courbe, puis regroupe les sous-champs résultants en unNodeField/ElementField. Pour des scalaires, elle rend une liste de flottants (il n’existe pas d’agrégat de flottant).
Evolution (agrégat)
├── SubEvolution zone 0 ── abscisses [t₀, t₁, …] × valeurs [v₀, v₁, …]
├── SubEvolution zone 1 ── …
└── …
Interpolation linéaire
Entre deux échantillons encadrants x_lo ≤ x ≤ x_hi, le résultat est le mélange
v_lo·(1−t) + v_hi·t avec t = (x − x_lo) / (x_hi − x_lo). Pour les champs, le
mélange réutilise l’arithmétique de champs (map_all + merge_components
précédé de check_same_components, cf.
Champ) : aucune logique numérique n’est dupliquée, et la
compatibilité des supports/composantes des deux champs encadrants est vérifiée à
ce moment-là. Une abscisse tombant exactement sur un échantillon rend la valeur
telle quelle.
Types d’abscisse et d’ordonnée
Une évolution peut porter le type physique de ses axes :
abscissa_type— le type de l’abscisse (p. ex."T","time"). Valable pour toutes les évolutions. Il sert à étiqueter les tracés (axe X d’une courbe, slider d’un champ) et, lorsqu’on interpole un champ, à choisir la composante du champ à lire (voir ci-dessous).ordinate_type— le type de la valeur, pour les évolutions scalaires uniquement (p. ex."young"). Il étiquette l’axe Y et nomme la composante produite quand on interpole un champ. Le donner sur une évolution de champs est une erreur (un champ a déjà ses propres composantes).
se = pc.SubEvolution(
[(0.0, 0.0), (100.0, 210e9)], abscissa_type="T", ordinate_type="young"
)
Interpoler un champ (courbe de transfert)
Une évolution scalaire à une seule courbe s’utilise comme une fonction de
transfert y = f(x) : au lieu d’un scalaire, on lui passe un champ et elle
rend un autre champ de même support, où chaque nœud / point de Gauss est
l’interpolation de la valeur d’entrée sur la courbe.
- La composante lue dans le champ d’entrée est celle nommée comme
l’
abscissa_type— la correspondance de type est vérifiée : si le champ n’a pas de composante de ce nom, c’est une erreur. - Le champ de sortie a une seule composante, nommée d’après l’
ordinate_type(à défaut"value"). - La politique hors-plage s’applique valeur par valeur, comme pour un scalaire.
# Loi matériau E(T) : module d'Young fonction de la température.
loi = pc.Evolution(
[(0.0, 0.0), (100.0, 210e9)], abscissa_type="T", ordinate_type="young"
)
young = loi.interpolate(temperature) # temperature : NodeField de composante "T"
# young : NodeField de composante "young"
Côté Evolution (agrégat), l’appel exige une seule courbe scalaire (sans
quoi le choix de la courbe serait ambigu) ; une SubEvolution s’interpole
directement.
Politique hors plage
Chaque évolution porte une politique appliquée quand l’abscisse demandée
sort de l’intervalle tabulé [x_min, x_max] :
| Politique | Effet hors plage |
|---|---|
"error" (défaut) | lève une erreur |
"clamp" | renvoie la valeur de l’extrémité la plus proche (pas d’extrapolation) |
"extrapolate" | prolonge linéairement avec le segment extrême |
La politique stockée peut être surchargée à l’appel :
evol.interpolate(x, out_of_range="clamp").
Construction
Deux voies, le constructeur haut niveau n’étant que du sucre au-dessus du
primitif bas niveau (motif model.heat_conduction(fes) / SubModel + |) :
- temps-major (haut niveau) —
Evolution([(t0, champ0), (t1, champ1), …])avec unNodeField/ElementField/ flottant complet par pas ; les champs entiers sont transposés en une courbe par zone (zones appariées entre pas par leur support, identique d’un pas à l’autre) ; - zone-major (bas niveau) — construire chaque
SubEvolutiondepuis sa liste(abscisse, sous-champ), puis agréger avec|.
L’union | et le slicing réinitialisent la politique hors-plage de
l’agrégat à "error".
API Rust
#[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(())
}
API Python
import pyrucast as pc
# Scalar curve (one SubEvolution).
se = pc.SubEvolution([(0.0, 10.0), (1.0, 20.0)])
print(se.interpolate(0.5)) # 15.0
print(se.interpolate(2.0, out_of_range="clamp")) # 20.0 (otherwise: an error)
# Agrégat scalaire → liste de flottants.
e = pc.Evolution([(0.0, 10.0), (1.0, 20.0)])
print(e.interpolate(0.5)) # [15.0]
# Low level: composing per-zone curves with `|`.
agg = pc.SubEvolution([(0.0, 1.0), (1.0, 2.0)]) | pc.SubEvolution(
[(0.0, 3.0), (1.0, 4.0)]
)
print(agg.interpolate(0.5)) # [1.5, 3.5]
# High level, time-major: one whole NodeField per step → interpolated NodeField.
ev = pc.Evolution([(0.0, champ_t0), (2.0, champ_t1)])
champ = ev.interpolate(1.0) # NodeField halfway
# Courbe de transfert : passer un champ → champ (loi matériau E(T)).
loi = pc.Evolution(
[(0.0, 0.0), (100.0, 210e9)], abscissa_type="T", ordinate_type="young"
)
young = loi.interpolate(temperature) # composante "T" lue → composante "young"
Tracé
evolution.plot(...) visualise l’évolution : courbe X-Y pour des scalaires, champ + slider de
valeur tabulée pour des champs. Voir Visualisation › Tracé d’une évolution.
À défaut de x_label / y_label explicites, les étiquettes reprennent
l’abscissa_type (axe X d’une courbe, slider d’un champ) et l’ordinate_type
(axe Y d’une courbe).
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") # courbe scalaire
ev.plot(save="frame.png", frame=1) # tabulated field (one step)
Place dans le modèle
SubValue est un enum de stockage inline (scalaire / SubNodeField /
SubElementField), comme SubModel l’est pour les physiques. SubEvolution
s’adresse par un Handle<SubEvolution> et sérialise ses valeurs en ligne
via le trait Portable ; les courbes sont donc portables
comme tout autre objet. L’homogénéité du type de valeur est garantie
à la construction (au sein d’une courbe) et par check_push (entre zones d’un
même agrégat).
Détails des physiques
Chaque physique est une variante de SubModel : elle déclare
ses variables (primales / duales), son matériau, et sait assembler sa rigidité
K (et, le cas échéant, intégrer son comportement COMP). Le Model
orchestre, mais ne porte aucune logique physique — voir
Modèle physique pour la mécanique générique (DOFs, assemblage,
chargements, solveur).
Chaque page suit le même plan standard, dans cet ordre :
- Introduction — nature du modèle, éléments et degrés de liberté.
- Équations continues résolues — forme forte (et faible) du problème.
- Forme discrétisée — opérateurs discrets (
B,N) et expressions des matrices élémentaires (K, masse/capacité, tangente…). - Variables et matériau — noms primal/dual, composantes matériau,
comportement (
COMP) et, le cas échéant, état interne. - Mise en donnée (Rust, testé) — exemple Rust exécuté via
{{#include}}. - Exemple Python — l’équivalent haut niveau.
- Compléments (facultatif) — extensions propres au modèle (masse & rigidité géométrique, thermomécanique, convection, régime transitoire…).
Certaines physiques omettent une rubrique sans objet (p. ex. pas d’exemple Rust dédié pour un modèle piloté par le comportement) ; l’ordre reste identique.
- Conduction thermique —
-∇·(k∇T) = 0, l’exemple canonique, la conduction orientée (tenseurK) et la convection de surface (Robin / film,q·n = h(T − T_ext)), plus le rayonnement à l’infini (q·n = σε(T⁴ − T_∞⁴), non linéaire, à tangente cohérente). - Diffusion (loi de Fick) —
∇·(D∇c) = 0, concentrationc_<espèce>et flux de matièrej_<espèce>, l’espèce étant nommée à la construction ; même opérateur que la conduction, nature distincte. Avec le transfert d’interfacej·n = h(c₁ − c₂), qui laisse le champ sauter entre deux corps. - Échanges (frontière et interface) — la loi
h(a − b), partagée par l’échange avec une ambiante et le transfert entre deux maillages : une seule loi, dont la seule différence est que l’autre côté soit une donnée ou une inconnue. Générique en composantes, donc aussi bien un film thermique qu’une fondation élastique. - Mécanique — barre, élasticité linéaire, poutres et
portiques :
- Barre / treillis
- Élasticité linéaire
- Élasticité orthotrope et anisotrope
- Plasticité parfaite (von Mises)
- Lois d’écoulement plastique — écrouissage isotrope, Drucker-Prager, Ottosen
- Fluage et viscoplasticité — Norton, Lemaitre, Blackburn, Chaboche et sa variante endommageable
- Endommagement de Mazars
- Lois d’endommagement — Damage TC, SiC/SiC orthotrope, plasticité poreuse de Gurson
- Poutre d’Euler-Bernoulli — 1-D, plan, spatial ; exacte aux nœuds
- Poutre de Timoshenko
- Portique 2D
- Cadre 3D
- Coques — Reissner-Mindlin ou Kirchhoff discret, six DDL par nœud
- Contraintes — conditions limites imposées par multiplicateurs de Lagrange :
Ce regroupement est la nature physique (Physics) que chaque variante
déclare : Thermal (conduction), Radiation (rayonnement, en plus de
Thermal), Diffusion (Fick), Mechanical (barre →
cadre 3D) et Constraint (les contraintes de Lagrange). On sélectionne les sous-modèles
d’une nature avec model.filter(Physics::Mechanical) (et les blocs d’une
matrice avec k.filter(...)) — voir
Nature physique et filtrage.
Ce que chacune sait produire
La liste ci-dessus dit où lire chaque physique. Les tableaux qui suivent
disent ce qu’elle sait produire : les genres de matrice qu’elle déclare
(MatrixKind),
la voie par laquelle elle obtient sa tangente, la nature de son intégration de
comportement, et la particularité de calcul qui la distingue.
Ils se lisent en colonnes. Une case remplie dit que la physique déclare ce
terme et que l’assembleur l’appelle ; un tiret dit qu’elle n’y contribue rien —
et ce n’est pas un manque. matrix.mass(...) sur un modèle contenant un
échange de bord voit simplement qu’il n’a pas de masse.
Les tags donnés sous chaque nom sont les chaînes exactes que prennent les
opérateurs (from_name) : c’est ce qu’on écrit, pas une paraphrase.
Thermique — primale T, duale q, filter("thermal")
| Physique | tangent | geometric | mass | Comportement (COMP) | Particularité de calcul |
|---|---|---|---|---|---|
heat_conductionisotropic orthotropic anisotropic | — | — | capacité ρ·cp | flux K·∇T | Symétrie matériau iso / ortho / aniso. La conductivité isotrope est lue par point de Gauss — donc variable à l’intérieur d’une maille ; les constantes orientées le sont par maille. |
boundary_transfercomposantes libres | — | — | — | h·a | Aveugle à l’orientation du maillage de bord : la normale est déjà consommée en écrivant q·n = h(T − T_ext), et la mesure d’intégration est une magnitude. |
radiation | analytique4σεT³ | — | — | σε(T⁴ − T_∞⁴) | Non linéaire. La rigidité est le film linéarisé autour de T_∞, donc constante : c’est l’opérateur dont part Newton. La tangente porte la vraie non-linéarité. Seule physique à déclarer deux natures, [Thermal, Radiation]. |
Diffusion — primale c_<espèce>, duale j_<espèce>, filter("diffusion")
| Physique | tangent | geometric | mass | Comportement (COMP) | Particularité de calcul |
|---|---|---|---|---|---|
fickisotropic orthotropic anisotropic | — | — | stockage poro | flux D·∇c | Même opérateur que la conduction, nature distincte : partager un laplacien n’est pas partager une physique, et un problème couplé doit pouvoir sélectionner l’une sans l’autre. L’espèce est nommée à la construction et portée par chaque nom (c_H2, j_H2, D_H2), ce qui laisse deux espèces partager un maillage — mais pas par ce qui appartient au milieu (poro, les axes V1X…). |
interface_transfercomposantes libres | — | — | — | h·(c₁ − c₂) | Quatre blocs — deux diagonaux, deux Coupling dont les lignes vivent sur un maillage et les colonnes sur l’autre. Leur scatter est séquentiel : le coloriage qui rend le scatter parallèle sûr repose sur une seule connectivité. Conformité vérifiée jusqu’au nœud. |
Mécanique — milieux continus, primales u, duales f, filter("mechanical")
| Physique | tangent | geometric | mass | Comportement (COMP) | Particularité de calcul |
|---|---|---|---|---|---|
elasticityplane_stress plane_strain axisymmetric full_3d · isotropic orthotropic anisotropic | analytique c’est K | oui | oui | σ = D·ε | Loi linéaire, donc la tangente est la rigidité. Symétrie matériau iso/ortho/aniso : le repère d’orthotropie passe par des vecteurs du champ matériau, et la rotation du tenseur se fait à l’ordre 4 plutôt que par une matrice de Bond. |
plasticity — 10 loisperfect isotropic drucker_prager ottosen gurson creep_norton creep_blackburn creep_lemaitre viscoplastic_chaboche viscoplastic_lemaitre_chaboche | analytique ×2 perturbation ×8 | oui | oui | σ, ε_p, p+ variables de la loi | La loi d’écoulement est un attribut. Tangente analytique pour les deux lois von Mises, par perturbation pour les huit autres — et toujours symétrisée. Les cinq lois visqueuses (Norton, Blackburn, Lemaitre, Chaboche…) erronent sans dt plutôt que d’intégrer comme si le temps n’existait pas. |
damage — 3 loismazars damage_tc sic_sic | aucune | oui | oui | σ, damage+ histoire de la loi | Pas de tangente, délibérément : l’opérateur d’itération reste la rigidité non endommagée. Damage TC porte deux histoires indépendantes — c’est ce qui laisse une fissure refermée reprendre toute sa charge, ce qu’un scalaire ne peut pas. |
Mécanique — structurel, efforts de section, filter("mechanical")
| Physique | tangent | geometric | mass | Comportement (COMP) | Particularité de calcul |
|---|---|---|---|---|---|
truss | — | oui N/L·P | oui ρA | N = E·A·ε | Forme fermée globale : la direction vient des coordonnées, sans matrice de repère. Marche en 1-D, 2-D et 3-D sans changement. |
bernoulliaucun tag | — | oui¹ | oui | MN, MN, M_y, M_z, T | Seule physique bâtie sur une interpolation C¹ : elle exige un espace HERMITE3, dont la base cubique la rend exacte aux nœuds — un élément par barre suffit. La configuration (1-D, plan, spatial) se déduit de la dimension du maillage. Ne demande ni G ni A_s : réclamer une constante qu’une théorie n’utilise pas, c’est inviter la mauvaise. |
timoshenkoaucun tag | — | oui¹ | oui | M, VN, M, VN, M_y, M_z, T, V_y, V_z+ phi | Une physique pour les trois configurations, lues sur la dimension du maillage — elle remplace frame et frame3d. Élément exact (forme fermée en Φ = 12EI/G·A_s·L²), donc espace MODEL_EMBEDDED : la base dépend du matériau, aucun espace ne peut la tabuler. Seule physique à porter dans son état une grandeur qui n’est pas un effort : Φ lui-même, parce que son B en dépend et que le noyau de forces internes reçoit l’état, non le matériau. ¹ sauf en flexion pure, qui n’a pas d’effort axial. |
shellthick, kirchhoff | — | — | — | N_xx…N_xyM_xx…M_xyM_drillQ_xz, Q_yz (thick seul) | Six DDL par nœud, les mêmes que la poutre en configuration spatiale, donc coque et portique partagent des nœuds sans adaptateur. Le vrillage est lié à la rotation de membrane, pas pénalisé : une pénalité diagonale s’opposerait à une rotation rigide, qui ne coûte rien. Il travaille, donc il a un effort conjugué (M_drill) : un résidu qui l’omettrait serait faux du terme même qui désingularise l’élément. thick (Reissner-Mindlin) est multi-quadrature — membrane et flexion au Gauss complet, cisaillement transverse en intégration réduite, ce qui empêche le blocage. kirchhoff (DKT/DKQ) n’a aucun cisaillement : γ = 0 est imposé aux sommets et le long de chaque arête, la limite mince est donc exacte par construction et il ne reste rien à bloquer — mais aussi aucun Q de comportement, l’effort tranchant d’une plaque mince étant une réaction. |
Contraintes — multiplicateurs de Lagrange, filter("constraint")
| Physique | tangent | geometric | mass | Comportement (COMP) | Particularité de calcul |
|---|---|---|---|---|---|
flux | — | — | — | — | La seule physique dont le terme entier siège à droite du signe égal : sa dérivée est nulle, elle ne contribue à aucune matrice. Premier domaine sans comportement — elle intègre sur un espace EF et y lit sa densité phi_<dual>, sans loi à évaluer. Elle n’a pas de primale non plus : elle écrit dans la ligne duale d’une autre physique. |
dirichlet · mpc · embedded · contact | — | — | — | — | Aucun layout, rien d’intégré sur une maille : elles redéfinissent directement contributions() et rendent leurs blocs C / Cᵀ en Literal. L’assembleur reste sans le moindre cas particulier « Dirichlet ». |
« Par perturbation » veut dire différences centrées
Pour les huit lois qui n’ont pas de tangente en forme fermée, D_alg est obtenu
en perturbant la déformation et en relançant le retour, composante par
composante :
\[ 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 . \]
Six composantes de Voigt, deux évaluations chacune : douze appels au retour
par point de Gauss. Le pas doit rester bien au-dessus du bruit du retour (celui
d’Ottosen ou de Gurson converge à une tolérance, pas exactement) et bien en
dessous de l’échelle de courbure de la surface ; 1e-6·‖ε‖ tient
confortablement entre les deux. Les colonnes de cisaillement sont divisées par
deux en sortie, ce qui transforme ∂σ/∂ε_ij en ∂σ/∂γ_ij — la convention
ingénieur du reste du dépôt.
| voie | lois |
|---|---|
| analytique | perfect, isotropic (le module algorithmique J2) |
| par perturbation | drucker_prager, ottosen, gurson, les trois fluages, les deux Chaboche |
Le partage n’est pas une question de difficulté mais de vérifiabilité : seule la forme fermée de von Mises a été confrontée à une différence finie et validée. La dérivation analytique de Drucker-Prager, écrite d’abord, était fausse de 24 % — plausible, et fausse ; seul l’oracle numérique l’a dit. Une tangente obtenue par perturbation ne peut pas être mal dérivée, coûte douze évaluations d’une mise à jour bon marché, et laisse Newton converger. C’est un bon échange.
Le rayonnement, lui, a bien une tangente analytique (4σεT³ ∫NᵢNⱼ) : sa
non-linéarité est une puissance scalaire d’une seule variable, pas une carte de
projection.
Trois choses que ces tableaux ne disent pas
Les forces internes ne suivent pas toujours Bᵀσ. Le défaut est le noyau de
la mécanique des milieux continus, f_i = ∫ ∂N_i/∂x · σ. Une physique dont la
duale n’est pas un vecteur déplacement le redéfinit : la thermique et la
diffusion appliquent Bᵀ à un flux scalaire, tandis que la convection, le
rayonnement et le transfert d’interface pondèrent par N et non par Bᵀ —
leur intégrande est une densité surfacique, pas une grandeur conjuguée d’un
gradient.
Les structurels le redéfinissent aussi, pour une autre raison : leur B
existe, mais ce n’est pas le gradient symétrique. Barre, poutres et coques
intègrent chacun le transposé du leur — truss::internal_force_element,
beam::internal_force_into, shell::b_into — et c’est le même B que
leur rigidité, ce que mesure tests/internal_forces.rs : la loi structurelle
étant linéaire, ∫ Bᵀσ vaut K·u exactement, à l’arrondi près.
Deux d’entre eux ont fallu payer un prix pour cela. Une poutre de Timoshenko
porte Φ dans son état, son interpolation en dépendant. Une coque a gagné un
effort conjugué, M_drill : le vrillage travaillait dans K sans figurer
nulle part ailleurs.
La tangente est symétrisée. D_alg est réduit au triangle supérieur puis
relu en miroir, un format qui ne peut structurellement pas porter une matrice non
symétrique. Or un
écoulement non associé — Drucker-Prager, dont la dilatance diffère du
frottement — en a une. Elle est donc symétrisée, ce qui coûte à Newton son taux
quadratique sur cette loi, et rien d’autre. Voir
Lois d’écoulement plastique.
État absent ≠ état nul. Au-delà de ε_p et p, une loi déclare l’état
qu’elle veut par internal_names() : la déformation primaire de Blackburn, la
contrainte de rappel de Chaboche (un tenseur complet), la porosité de Gurson, les
deux histoires de Damage TC. Le premier pas passe un vecteur vide — et non
un vecteur de zéros — pour qu’une loi démarrant d’une constante matériau puisse
faire la différence. Sans cela, un métal poreux démarrerait dense et ne
s’endommagerait jamais.
Pour ajouter une physique, voir Ajouter une physique.
Conduction thermique
Cette page décrit la physique de conduction thermique (HeatConduction) et
la convection de surface associée. Elle suit le plan standard des physiques puis
la déroule sur un exemple complet — une ligne chauffée par une source à une
extrémité et maintenue à température fixe à l’autre — comparé à la solution
analytique.
Pour la mécanique générique du Model (orchestration, DOFs, assemblage), voir
Modèle physique. Ici on se concentre sur le cas thermique.
Équations continues résolues
En régime stationnaire, la forme forte est
\[ -\nabla\cdot\big(k\,\nabla T\big) = 0, \]
et en régime transitoire, l’équation de la chaleur porte un terme de stockage :
\[ \rho\,c_p\,\frac{\partial T}{\partial t} - \nabla\cdot(k\,\nabla T) = Q. \]
La forme variationnelle de Galerkine (multiplication par une température virtuelle, intégration par parties) fait apparaître la rigidité (conduction) et, en transitoire, la capacité (stockage) — leurs formes discrètes ci-dessous.
Forme discrétisée
La conductivité donne, cellule par cellule, la matrice de rigidité :
\[ K_{ij} = \int_K k(x)\,\nabla N_i\cdot\nabla N_j\,dx \quad\approx\quad \sum_g k(\xi_g)\,(\nabla N_i\cdot\nabla N_j)\big|_g\,|J|_g\,w_g \]
(implémentée dans src/models/heat_conduction.rs). En notant
\( B = [\nabla N_1, \dots, \nabla N_n] \) la matrice des gradients de forme
(taille \( d\times n \)), on a aussi \( K = \int_\Omega k\, B^\top B\, d\Omega \).
Le bloc local est écrit aux positions row = (NodeId_i, "q"),
col = (NodeId_j, "T"). Pour un SEG2 de longueur \(L\) et \(k\) uniforme on
retrouve la matrice analytique \((k/L)\,[[1,-1],[-1,1]]\).
En transitoire, le terme de stockage discrétise en une matrice de capacité
(l’analogue thermique de la matrice de masse, Cast3M CAPA) :
\[ C_{ij} = \int_\Omega \rho\,c_p\,N_i\,N_j\,d\Omega \;\approx\; \sum_g \rho\,c_p\,N_i(\xi_g)\,N_j(\xi_g)\,|J|_g\,w_g, \]
assemblée par assemble.mass (matériau rho, cp)
et concentrable en diagonale par lump. Le système
semi-discret est \( C\,\dot T + K\,T = F \) ; l’intégration en temps
(θ-schéma, Euler implicite (C/\Delta t + K)) se pilote dans la couche Python.
Variables et matériau
| nom | rôle | |
|---|---|---|
| primale (colonnes, inconnue) | "T" | température |
| duale (lignes, second membre) | "q" | flux de chaleur |
| matériau | "k" | conductivité (au point de Gauss) ; rho, cp facultatifs (capacité) |
La conductivité peut être orientée — voir
Conduction orthotrope et anisotrope plus
bas ; "k" est alors remplacée par les constantes de la symétrie choisie.
Mise en donnée (Rust, testé)
Le pipeline est toujours le même :
Coords— l’espace des nœuds (dimension géométrique).Mesh— les éléments (ici desSEG2alignés sur \([0,1]\)).FiniteElementSpace— l’interpolation (lagrange1).- Matériau — un
ElementFieldportant la composante"k", fabriqué commodément parelement_field::material_field(&model, &[("k", …)])(les sous-modèles sans matériau, commeDirichlet, sont ignorés). Model—model::heat_conduction(&fes), composé par|(union) avec les conditions limites.- Conditions limites :
- Dirichlet (
Timposée) : un sous-modèlemodel::dirichletqui impose la valeur via multiplicateurs de Lagrange. L’utilisateur fournit le maillage des nœuds imposés et le maillage support des multiplicateurs — typiquement fabriqué depuis le premier avec le mesher génériquebarycenter(nœuds neufs colocalisés). La valeur imposée \(u_d\) s’écrit dans le chargement au slotimposed_Tdu nœud-multiplicateur (cf. Modèle physique). - Neumann / source : une charge ponctuelle est une valeur du
chargement sur la composante duale
"q"au nœud concerné ; un flux réparti sur un bord (ou un volume) se transforme en charges nodales cohérentes par l’opérateurflux(analogue deFLUX/PRESde Cast3M).
- Dirichlet (
- Assemblage + résolution —
matrix::stiffnesspuis le solveursolver::lu::solve(LU creuse directe, voir Modèle physique).
Exemple : ligne chauffée
Problème. Sur \([0,1]\), une source de chaleur (flux de Neumann \(Q\)) est appliquée en \(x=0\), et la température est imposée à \(T=20\) en \(x=1\).
Solution analytique. Sans génération volumique, \(T’’=0\) : le profil est linéaire. En notant \(Q\) le flux injecté et \(k\) la conductivité,
\[ u(x) = 20 + \frac{Q}{k}\,(1 - x). \]
De plus, le multiplicateur de Lagrange au nœud imposé (la réaction qui maintient \(T=20\)) vaut exactement \(Q\) : tout le flux injecté en \(x=0\) ressort en \(x=1\) — un bilan d’énergie discret.
Code. L’exemple ci-dessous est le test d’intégration
tests/thermal_line.rs : il est compilé et exécuté à chaque cargo test,
donc garanti à jour avec l’API.
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::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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 multipliers' support is built from the imposed node by the
// `barycenter` mesher (a fresh co-located node). The model creates nothing.
let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(std::slice::from_ref(
nodes.last().unwrap(),
))?);
let multiplier = mesh::barycenter(&imposed)?;
let mult = multiplier.node(0, 0, 0)?.id();
let conduction = model::heat_conduction(&fes)?;
let dirichlet = model::dirichlet(&conduction, "T", &imposed, &multiplier, Default::default())?;
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_T" 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_T".into(), "q".into()])?;
rhs.set_value(node0, "q", Q)?;
rhs.set_value(mult, "imposed_T", 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_T")?;
assert!(
(reaction - Q).abs() < tol,
"réaction λ : obtenue {reaction}, attendue {Q}"
);
Ok(())
}
Exemple Python
La version Python équivalente et documentée est dans le dépôt :
examples/thermal_line_1d.py (lancer avec python examples/thermal_line_1d.py
après maturin develop). Les compléments 2-D ci-dessous ont eux aussi leur
variante Python (thermal_square_2d.py, thermal_convection_2d.py).
Compléments
Exemple : un carré
La généralisation 2-D du cas précédent : un carré \([0,1]^2\) (grille
structurée de QUA4), chauffé par une source répartie sur le bord gauche
(\(x=0\)) et maintenu à \(T=20\) sur le bord droit (\(x=1\)). Les bords
haut et bas ne portent aucune condition : c’est la condition naturelle
(flux nul, bord isolé).
Comme les bords latéraux sont isolés, le champ ne dépend pas de \(y\) : le carré redonne le profil de la ligne,
\[ u(x) = 20 + \frac{Q}{k}\,(1 - x), \]
et la réaction totale (somme des multiplicateurs sur le bord imposé) vaut le flux injecté \(Q\).
Mise en donnée d’un flux réparti. Une source répartie se transforme en
charges nodales cohérentes \(f_i = \int_\Gamma \varphi\,N_i\,d\Gamma\) par
l’opérateur flux — l’analogue de FLUX/PRES de Cast3M. On lui donne le bord
(ici un maillage SEG2, intégré comme une ligne : la mesure vient du
Jacobien manifold) et la densité de flux (une constante, ou un champ par
éléments) ; il renvoie un NodeField sur la composante duale "q", prêt à
composer (|) avec le reste du chargement. Sous le capot, pour un flux uniforme
sur des éléments linéaires, un nœud intérieur du bord reçoit \(Q\,h\) et un
coin \(Q\,h/2\) (somme \(Q\)) — mais on n’a plus à le calculer à la main.
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::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::solve;
use pyrucast::Result;
#[test]
fn thermal_square_recovers_analytical_solution() -> Result<()> {
// ── Problem data ───────────────────────────────────────────────────────
const K: f64 = 1.0; // conductivité
const Q: f64 = 10.0; // flux de chaleur TOTAL injecté sur le bord gauche
const T_IMPOSED: f64 = 20.0; // température imposée sur le bord droit
const N: usize = 4; // N×N éléments QUA4
let h = 1.0 / N as f64;
// ── Mesh: a structured (N+1)×(N+1) grid of QUA4 on [0,1]² ──────────────
let coords = Handle::new(Coords::new(2)?);
let idx = |i: usize, j: usize| j * (N + 1) + i; // nœud colonne i, ligne j
let mut grid: Vec<Node> = Vec::with_capacity((N + 1) * (N + 1));
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)?;
// ── Dirichlet T = 20 on the right edge (x = 1) ──────────────────────────
let right_nodes: Vec<Node> = (0..=N).map(|j| grid[idx(N, j)].clone()).collect();
let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(&right_nodes)?);
let multiplier = mesh::barycenter(&imposed)?;
let mults: Vec<Node> = (0..=N)
.map(|j| multiplier.node(0, j, 0))
.collect::<Result<_>>()?;
let conduction = model::heat_conduction(&fes)?;
let dirichlet = model::dirichlet(&conduction, "T", &imposed, &multiplier, Default::default())?;
let model = conduction.union(&dirichlet)?;
// ── Chargement ─────────────────────────────────────────────────────────
// Source: uniform flux (density Q) on the left edge, turned into
// consistent nodal loads by the `flux` operator (Cast3m FLUX) — no more
// Q·h / Q·h/2 distribution by hand. The edge is a SEG2 mesh built on
// the grid's nodes; it integrates as a line.
let mut left_edge = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
for j in 0..N {
left_edge.add_cell(&[grid[idx(0, j)].id(), grid[idx(0, j + 1)].id()])?;
}
let left_fes = FiniteElementSpace::lagrange1(&left_edge)?;
let model = model.union(&model::flux(&left_fes, &model, "q".into())?)?;
let materials =
pyrucast::ops::element_field::material_field(&model, &[("k", K), ("phi_q", Q)])?;
let source = pyrucast::ops::node_field::external_forces(&model, &materials)?;
// Imposed value T = 20 at the multiplier nodes' "imposed_T" slot.
let mut imposed_sm = SubMesh::new(coords.clone(), ElementType::POI1);
for m in &mults {
imposed_sm.add_cell(&[m.id()])?;
}
let imposed_sm = Handle::new(imposed_sm);
let mut imposed_load = SubNodeField::from_poi1(&imposed_sm, vec!["imposed_T".into()])?;
for m in &mults {
imposed_load.set_value(m.id(), "imposed_T", T_IMPOSED)?;
}
// Loading = the edge's flux + the imposed values (union of the zones).
let rhs = source.union(&NodeField::from_sub(imposed_load))?;
// ── Assemblage + résolution ────────────────────────────────────────────
let stiffness = pyrucast::ops::matrix::stiffness(&model, &materials)?;
let solution = solve(&stiffness, &rhs)?;
// ── Compared with the analytical u(x) = 20 + (Q/k)(1 − x), ∀ y ─────────
let tol = 1e-9;
for j in 0..=N {
for i in 0..=N {
let x = i as f64 * h;
let expected = T_IMPOSED + (Q / K) * (1.0 - x);
let got = solution.value(grid[idx(i, j)].id(), "T")?;
assert!(
(got - expected).abs() < tol,
"T(x={x}, y={}) : obtenu {got}, attendu {expected}",
j as f64 * h
);
}
}
// The total reaction on the imposed edge balances the injected flux: Σλ = Q.
let total_reaction: f64 = mults
.iter()
.map(|m| solution.value(m.id(), "lambda_T"))
.sum::<Result<f64>>()?;
assert!(
(total_reaction - Q).abs() < tol,
"réaction totale : obtenue {total_reaction}, attendue {Q}"
);
Ok(())
}
Version Python :
examples/thermal_square_2d.py(lancer avecpython examples/thermal_square_2d.pyaprèsmaturin develop).
Conduction orthotrope et anisotrope
Un matériau feuilleté, fibré ou laminé ne conduit pas la chaleur de la même façon
dans toutes les directions. La conductivité devient alors un tenseur K, et
la rigidité
\[ K_{ij} = \int_\Omega \nabla N_i^{\mathsf T}\, \mathbf{K}\, \nabla N_j \, d\Omega \]
dont le cas isotrope K = k·I redonne le produit scalaire habituel.
C’est le même axe de symétrie matériau qu’en mécanique (chapitre Élasticité orthotrope), avec un tenseur d’ordre 2 au lieu de 4 :
| symétrie | composantes matériau |
|---|---|
isotropic (défaut) | k |
orthotropic | k_1, k_2, k_3 + le repère matériau |
anisotropic | k_11, k_12, k_13, k_22, k_23, k_33 + le repère |
Le repère est donné par des vecteurs — V1X, V1Y en 2-D, V1X…V1Z, V2X…V2Z en 3-D — comme MATE 'DIRECTION' V1 V2 de Cast3M. Ils sont
orthonormalisés en interne.
model = pyrucast.model.heat_conduction(fes, symmetry="orthotropic")
materials = pyrucast.element_field.material_field(
model,
[("k_1", 12.0), ("k_2", 3.0), ("k_3", 12.0), ("V1X", cos_a), ("V1Y", sin_a)],
)
La conductivité isotrope reste lue au point de Gauss, donc variable à l’intérieur d’une maille ; les constantes orientées sont lues par maille, comme les modules mécaniques.
L’exemple Rust est un test de patch, qui est ce qu’appelle une conductivité
orientée : un champ de température linéaire est harmonique pour n’importe quel
tenseur constant, donc l’imposer au bord doit le reproduire à l’intérieur quelle
que soit K. Le test ne s’arrête pas là — il relit le flux produit et le
compare à K·∇T calculé à la main, ce qui est le seul moyen de prendre la
rotation en défaut :
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::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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(())
}
Avec ∇T = (1, 0), le flux est la première colonne de K :
K_xx = k₁cos²θ + k₂sin²θ et K_yx = (k₁ − k₂)·cosθ·sinθ. Le terme
extra-diagonal n’est non nul que si le matériau est à la fois anisotrope et
désaligné — précisément le cas qu’une rotation fausse manquerait.
Rayonnement à l’infini (Stefan-Boltzmann)
Une surface qui échange avec un environnement lointain à \(T_\infty\) rayonne
\[ q\cdot n = \sigma\,\varepsilon\,\big(T^4 - T_\infty^4\big) \]
où \(\sigma\) est la constante de Stefan-Boltzmann et \(\varepsilon\)
l’émissivité. Primale "T", duale "q" — les mêmes degrés de liberté que la
conduction, donc un bord rayonnant se couple directement dans sa rigidité, comme
la convection. Et comme elle, il n’a besoin d’aucune normale : la direction
est déjà consommée en écrivant q·n, il ne reste sous l’intégrale qu’un scalaire
et la mesure de surface.
Ce qui change par rapport à la convection : c’est non linéaire
La loi de Newton est linéaire en T, si bien que la convection ne contribue
qu’une matrice de film constante. T⁴ ne l’est pas, d’où trois termes :
| terme | expression | rôle |
|---|---|---|
| rigidité | 4σεT_∞³ ∫ NᵢNⱼ dΓ | le film radiatif linéarisé, un opérateur constant — le h_r classique |
| force interne | ∫ Nᵢ σε(T⁴ − T_∞⁴) dΓ | le résidu, exact |
| tangente | 4σεT³ ∫ NᵢNⱼ dΓ | la tangente cohérente à la température courante |
Linéariser la rigidité autour de \(T_\infty\) plutôt qu’autour de l’état
courant est ce qui la laisse être une matrice constante : c’est l’opérateur
dont on part pour une boucle de Newton, et à lui seul une itération de Picard
tout à fait utilisable. La tangente porte la vraie non-linéarité : elle évalue 4σεT³ à la
température courante, au point de Gauss, quand on la demande — comme le D_alg
plastique, et pour la même raison : personne d’autre ne la lirait.
Deux natures
Le rayonnement déclare [Thermal, Radiation]. Un bord rayonnant fait partie du
problème thermique — filter("thermal") doit le rendre — tandis que
filter("radiation") isole le terme non linéaire à part, pour l’assembler ou
l’inspecter seul. C’est le premier usage du caractère ensembliste de
physics().
Unités
sigma vaut par défaut la constante SI, et T est alors une température
absolue (Kelvin) : une puissance quatrième n’a aucune invariance permettant
de translater une origine. Dans un autre système d’unités, fournir sigma comme
composante matériau.
conduction = pyrucast.model.heat_conduction(volume)
model = conduction | pyrucast.model.radiation(bord, conduction)
materials = pyrucast.element_field.material_field(
model, [("k", 20.0), ("emis", 0.8), ("T_inf", 300.0)]
)
Ce que ça vaut comme vérification
Deux choses se contrôlent sans acrobatie analytique : le flux rayonné doit valoir
exactement σε(T⁴ − T_∞⁴) fois l’aire, et la tangente doit être la dérivée du
résidu. Une loi en T⁴ est précisément là où une tangente incohérente se cache
— Newton ramperait au lieu de converger quadratiquement — d’où sa comparaison à
une différence finie :
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::Physics;
use pyrucast::ops::element_field;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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, None, &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(())
}
Convection de surface (Robin / film)
Le modèle BoundaryTransfer (src/models/boundary_transfer.rs) ajoute un échange
convectif avec un fluide à température ambiante \(T_\text{ext}\) sur un
bord : la loi de Newton du refroidissement
\[ q\cdot n = h\,\big(T - T_\text{ext}\big) \]
où \(h\) est le coefficient d’échange (film). Injectée dans le terme de bord de la forme faible de la conduction, elle se scinde en deux ingrédients :
\[ \underbrace{K_{ij} = h \int_\Gamma N_i\,N_j\,d\Gamma}{\text{matrice de film (raideur)}} \qquad \underbrace{f_i = h\,T\text{ext} \int_\Gamma N_i\,d\Gamma}_{\text{charge (second membre)}} \]
On le construit contre la conduction qu’il refroidit, en lui passant les couples de variables à échanger — ceux de la conduction, ce qui fait que le terme se couple directement dans sa raideur. La conduction, elle, lui donne sa nature thermique, et refuse un couple qu’elle n’assemble pas :
conduction = pyrucast.model.heat_conduction(bord_fes)
film = pyrucast.model.boundary_transfer(bord_fes, conduction, [("T", "q")])
| nom | rôle | |
|---|---|---|
| primale | "T" | température (partagée avec HeatConduction) |
| duale | "q" | flux de chaleur (partagé) |
| matériau | "h_T" | coefficient d’échange (film), nommé d’après la grandeur |
| matériau | "a_ext_T" | température ambiante du fluide, exigée — l’omettre échouerait à l’assemblage plutôt que de valoir zéro en silence |
Ce modèle n’a rien de thermique : la même loi décrit un transfert de masse en surface ou une fondation élastique, selon les composantes qu’on lui donne, et il partage son noyau avec le transfert d’interface. Voir Échanges pour la loi commune, la structure en quatre blocs et le choix entre un échange et une contrainte.
Mise en donnée. Le modèle fournit la matrice de film ; la part externe
\(h\,T_\text{ext}\) est un chargement, bâti avec le même opérateur
flux que la source (densité \(h\,T_\text{ext}\)). Le
terme de film rend la matrice définie : un problème purement Neumann +
convection est bien posé sans Dirichlet.
Exemple. Une dalle \([0,1]^2\) chauffée par un flux \(Q\) sur le bord gauche et refroidie par convection sur le bord droit (haut/bas isolés). Tout le flux ressort par convection, d’où le profil linéaire
\[ T(x) = T_\text{ext} + \frac{Q}{h} + \frac{Q}{k}\,(1 - x). \]
use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::finite_element_space::FiniteElementSpace;
use pyrucast::containers::mesh::{Mesh, SubMesh};
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::solve;
use pyrucast::Result;
#[test]
fn thermal_convection_recovers_analytical_solution() -> Result<()> {
// ── Problem data ───────────────────────────────────────────────────────
const K: f64 = 2.0; // conductivité
const Q: f64 = 10.0; // densité de flux injectée sur le bord gauche
const H: f64 = 5.0; // coefficient d'échange (film) sur le bord droit
const T_EXT: f64 = 20.0; // température ambiante du fluide
const N: usize = 4; // N×N éléments QUA4
let step = 1.0 / N as f64;
// ── Mesh: a structured (N+1)×(N+1) grid of QUA4 on [0,1]² ──────────────
let coords = Handle::new(Coords::new(2)?);
let idx = |i: usize, j: usize| j * (N + 1) + i; // nœud colonne i, ligne j
let mut grid: Vec<Node> = Vec::with_capacity((N + 1) * (N + 1));
for j in 0..=N {
for i in 0..=N {
grid.push(Node::create_in(
coords.clone(),
&[i as f64 * step, j as f64 * step],
)?);
}
}
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 : conduction (volume) + convection (bord droit x = 1) ───────
// Le bord droit est un maillage SEG2 bâti sur les nœuds de la grille ;
// it integrates as a line (film matrix h ∫ N_i N_j dΓ).
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)?;
let conduction = model::heat_conduction(&fes)?;
let convection =
model::boundary_transfer(&right_fes, &conduction, vec![("T".into(), "q".into())])?;
let model = conduction.union(&convection)?;
// Material: k for the conduction, h and the ambient for the convection (each
// sub-model takes the component it requires from the supplied list).
// ── Chargement ─────────────────────────────────────────────────────────
// Source: uniform flux (density Q) on the left edge, as nodal loads
// cohérentes via `flux`.
let mut left_edge = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
for j in 0..N {
left_edge.add_cell(&[grid[idx(0, j)].id(), grid[idx(0, j + 1)].id()])?;
}
let left_fes = FiniteElementSpace::lagrange1(&left_edge)?;
let model = model.union(&model::flux(&left_fes, &model, "q".into())?)?;
let materials = pyrucast::ops::element_field::material_field(
&model,
&[("k", K), ("h_T", H), ("a_ext_T", T_EXT), ("phi_q", Q)],
)?;
// Both given terms — the left edge's source and the convection's external
// part h·T_ext — belong to the model, which returns them together. Nothing
// left to union by hand, hence nothing left to forget.
let rhs = pyrucast::ops::node_field::external_forces(&model, &materials)?;
// ── Assembly + solve (K made definite by the film term) ────────────────
let stiffness = pyrucast::ops::matrix::stiffness(&model, &materials)?;
let solution = solve(&stiffness, &rhs)?;
// ── Compared with the analytical T(x) = T_ext + Q/h + (Q/k)(1 − x), ∀ y ─
let tol = 1e-9;
for j in 0..=N {
for i in 0..=N {
let x = i as f64 * step;
let expected = T_EXT + Q / H + (Q / K) * (1.0 - x);
let got = solution.value(grid[idx(i, j)].id(), "T")?;
assert!(
(got - expected).abs() < tol,
"T(x={x}, y={}) : obtenu {got}, attendu {expected}",
j as f64 * step
);
}
}
// Energy balance: all the injected flux leaves by convection, so the right
// edge's temperature is exactly T_ext + Q/h.
let t_right = solution.value(grid[idx(N, 0)].id(), "T")?;
assert!(
(t_right - (T_EXT + Q / H)).abs() < tol,
"T(x=1) : obtenu {t_right}, attendu {}",
T_EXT + Q / H
);
Ok(())
}
Version Python :
examples/thermal_convection_2d.py(lancer avecpython examples/thermal_convection_2d.pyaprèsmaturin develop).
Diffusion (loi de Fick)
Introduction
La diffusion d’une espèce dans un milieu — humidité dans un béton, hydrogène dans un acier, chlorures dans un enrobage — obéit à la loi de Fick, dont l’opérateur est celui de la conduction thermique.
C’est pourtant une physique distincte, et pyrucast la traite comme telle. La
variable primale est la concentration c, la duale le flux de matière j, et sa
nature déclarée est Physics::Diffusion. Partager un opérateur n’est pas
partager une physique : dans un problème couplé thermo-diffusif, on doit pouvoir
écrire model.filter("diffusion") et n’obtenir que la partie diffusive, sans
traîner la thermique avec.
Le modèle vit sur n’importe quel espace EF volumique (2-D ou 3-D, linéaire ou quadratique), avec un degré de liberté scalaire par nœud.
Équations continues résolues
La première loi de Fick relie le flux au gradient de concentration :
\[ \mathbf j = -\,\mathsf D\,\nabla c \]
et la conservation de l’espèce, en régime transitoire avec un coefficient de stockage \( \varphi \) (la porosité, pour une espèce diffusant dans un solide poreux) :
\[ \varphi\,\frac{\partial c}{\partial t} + \nabla\!\cdot\mathbf j = 0 \qquad\Longleftrightarrow\qquad \varphi\,\frac{\partial c}{\partial t} - \nabla\!\cdot(\mathsf D\,\nabla c) = 0 . \]
En stationnaire c’est l’équation de Laplace pondérée par \( \mathsf D \). C’est la même équation que la conduction thermique, à un changement de noms près (\( c \leftrightarrow T \), \( \mathsf D \leftrightarrow k \), \( \varphi \leftrightarrow \rho c_p \)) — d’où un modèle qui partage la totalité du noyau de conduction thermique, et n’en diffère que par ses variables et sa nature physique.
La forme faible, après intégration par parties, s’écrit : trouver c tel que
pour tout δc admissible,
\[ \int_\Omega \varphi\,\delta c\,\dot c\; d\Omega
- \int_\Omega \nabla \delta c \cdot \mathsf D\,\nabla c\; d\Omega = -\int_{\partial\Omega} \delta c\;\mathbf j\!\cdot\!\mathbf n\; d\Gamma . \]
Forme discrétisée
\[ K_{ij} = \int_\Omega \nabla N_i^\top\,\mathsf D\;\nabla N_j\; d\Omega \quad \text{(rigidité de diffusion — Cast3M \texttt{COND})}, \] \[ C_{ij} = \int_\Omega \varphi\,N_i\,N_j\; d\Omega \quad \text{(stockage — Cast3M \texttt{CAPA})}. \]
D est un tenseur, dont le cas isotrope D = D·I redonne le produit
scalaire habituel ∇N_i · ∇N_j. Les trois symétries matériau décrites au
chapitre Élasticité orthotrope s’appliquent
identiquement, avec un tenseur d’ordre 2 au lieu de 4 :
| symétrie | composantes matériau |
|---|---|
isotropic | D |
orthotropic | D_1, D_2, D_3 + le repère matériau |
anisotropic | D_11, D_12, D_13, D_22, D_23, D_33 (symétrique) + le repère |
Le repère est donné par les vecteurs V1X, V1Y (2-D) ou V1X…V1Z, V2X…V2Z
(3-D), exactement comme en mécanique.
Variables et matériau
| primale | c (concentration, colonnes) |
| duale | j (flux de matière, lignes) |
| matériau requis | la diffusivité, selon la symétrie |
| matériau optionnel | poro — le coefficient de stockage, exigé par la seule matrice de masse |
| nature | Physics::Diffusion |
Le comportement (COMP) rend le flux sous forme faible D·∇c, en
composantes j_x, j_y(, j_z). Comme en thermique, c’est l’opposé du flux
physique de Fick : ce choix garantit ∫ Bᵀ·j = K·c, donc l’accord entre le
comportement et la rigidité dans le cas linéaire. Les composantes sont nommées
d’après la variable duale (j_*) et non flux_*, afin qu’un modèle portant à la
fois conduction et diffusion garde deux champs de flux non ambigus.
L’entrée du comportement est le gradient grad_c_x, …, tel que le produit
l’opérateur gradient sur un champ dont la composante
est c.
Mise en donnée (Rust, testé)
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::Physics;
use pyrucast::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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 multiplier = mesh::barycenter(&imposed)?;
let mult = multiplier.node(0, 0, 0)?.id();
let diffusion = model::fick(&fes, SPECIES)?;
let dirichlet = model::dirichlet(
&diffusion,
&primal_var(SPECIES),
&imposed,
&multiplier,
Default::default(),
)?;
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![
format!("imposed_{}", primal_var(SPECIES)),
dual_var(SPECIES),
],
)?;
rhs.set_value(node0, &dual_var(SPECIES), J)?;
rhs.set_value(mult, &format!("imposed_{}", primal_var(SPECIES)), 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, &format!("lambda_{}", primal_var(SPECIES)))?;
assert!((reaction - J).abs() < tol, "réaction λ : {reaction} ≠ {J}");
Ok(())
}
Exemple Python
"""1-D Fick diffusion — a bar fed with a species, compared with the analytical solution.
Problème
--------
On the segment [0, 1]:
* en x = 0 : un **flux d'espèce** imposé ``J`` (Neumann) ;
* at x = 1: an **imposed concentration** ``c = 1`` (Dirichlet).
In the steady regime without a volume source the profile is linear ::
c(x) = 1 + (J / D) * (1 - x)
and the Lagrange multiplier at the imposed node is exactly ``J``: everything
entering at x = 0 leaves at x = 1 (mass balance).
The operator is the thermal conduction one; what changes is the **physics**.
The primal is the concentration ``c``, the dual the flux ``j``, and the kind
declared is ``"diffusion"`` — so that a coupled thermo-diffusive model splits
with ``model.filter(...)``, which the second part
de l'exemple montre.
This is the Python equivalent of the Rust integration test ``tests/fick.rs``.
Lancement
---------
Once the extension is built in the venv ::
maturin develop --features extension-module
python examples/diffusion_1d.py
"""
import pyrucast
# ── Problem data ────────────────────────────────────────────────────────────
SPECIES = "H2" # the diffusing species — every name carries it
D = 2.0 # diffusivité
J = 10.0 # flux d'espèce injecté en x = 0
C_IMPOSED = 1.0 # concentration imposée en x = 1
N_ELEMS = 4
K = 5.0 # thermal conductivity, for the coupled part
def ligne(n_elems):
"""A line of ``n_elems`` SEG2 on [0, 1], with its nodes."""
c = pyrucast.Coords(1)
h = 1.0 / n_elems
nodes = [c.add_node([i * h]) for i in range(n_elems + 1)]
mesh = pyrucast.Mesh(c, "SEG2")
for i in range(n_elems):
mesh.unit().add_cell([nodes[i], nodes[i + 1]])
return c, nodes, pyrucast.FiniteElementSpace(mesh), h
def profil_stationnaire() -> None:
c, nodes, fes, h = ligne(N_ELEMS)
# ── Modèle : diffusion + concentration imposée en x = 1 ──────────────────
imposed = pyrucast.Mesh(c, "POI1")
imposed.unit().add_cell([nodes[-1]])
multiplier = pyrucast.mesh.barycenter(imposed)
mult = multiplier.node(0, 0, 0)
cible = pyrucast.model.fick(fes, SPECIES)
model = cible | pyrucast.model.dirichlet(cible, f"c_{SPECIES}", imposed, multiplier)
materials = pyrucast.element_field.material_field(model, [(f"D_{SPECIES}", D)])
# ── Loading: flux J at x = 0, imposed value at the multiplier ────────────
load = pyrucast.Mesh(c, "POI1")
load.unit().add_cell([nodes[0]])
load.unit().add_cell([mult])
rhs = pyrucast.NodeField(load, [f"imposed_c_{SPECIES}", f"j_{SPECIES}"])
rhs[0].set_value(nodes[0], f"j_{SPECIES}", J)
rhs[0].set_value(mult, f"imposed_c_{SPECIES}", C_IMPOSED)
# ── Assemblage + résolution ─────────────────────────────────────────────
stiffness = pyrucast.matrix.stiffness(model, materials)
solution = pyrucast.solver.solve(stiffness, rhs)
print("Diffusion de Fick 1-D")
print(f" D = {D}, flux injecté J = {J}, c(1) = {C_IMPOSED}")
print()
print(" x c calculé c analytique")
print(" " + "-" * 36)
for i, node in enumerate(nodes):
x = i * h
attendu = C_IMPOSED + (J / D) * (1.0 - x)
obtenu = solution.value(node, f"c_{SPECIES}")
print(f" {x:5.3f} {obtenu:10.6f} {attendu:12.6f}")
assert abs(obtenu - attendu) < 1e-10
reaction = solution.value(mult, f"lambda_c_{SPECIES}")
print()
print(f" Bilan de matière : réaction = {reaction:.6f}, flux injecté = {J}")
assert abs(reaction - J) < 1e-10
def couplage_avec_la_thermique() -> None:
"""Diffusion and conduction on the same mesh: two distinct physics."""
_c, _nodes, fes, _h = ligne(3)
model = pyrucast.model.fick(fes, SPECIES) | pyrucast.model.heat_conduction(fes)
# A single material field carries both sets: the assembler resolves each zone
# through the components its physics requires (`D` here, `k` there).
materials = pyrucast.element_field.material_field(
model, [(f"D_{SPECIES}", D), ("k", K)]
)
pyrucast.matrix.stiffness(model, materials)
print()
print("Modèle couplé diffusion + thermique")
print(f" sous-modèles : {len(model)}")
print(f" filter('diffusion') : {len(model.filter('diffusion'))}")
print(f" filter('thermal') : {len(model.filter('thermal'))}")
print(f" filter('mechanical') : {len(model.filter('mechanical'))}")
assert len(model.filter("diffusion")) == 1
assert len(model.filter("thermal")) == 1
assert len(model.filter("mechanical")) == 0
def main() -> None:
profil_stationnaire()
couplage_avec_la_thermique()
if __name__ == "__main__":
main()
Compléments
Coexister avec la thermique
Les deux physiques peuvent vivre sur le même maillage sans se gêner :
model = pyrucast.model.fick(fes, "H2") | pyrucast.model.heat_conduction(fes)
materials = pyrucast.element_field.material_field(model, [("D_H2", 2.0), ("k", 5.0)])
k = pyrucast.matrix.stiffness(model, materials)
len(model.filter("diffusion")) # 1
len(model.filter("thermal")) # 1
Un seul champ matériau porte les deux jeux de coefficients. L’assembleur résout
la zone de chaque physique par les composantes qu’elle exige (D ici, k
là) — il n’y a rien à consolider à la main. Et parce que les deux natures sont
distinctes, filter les sépare de nouveau après coup, aussi bien sur le modèle
que sur la matrice assemblée.
Les degrés de liberté restent séparés (c d’un côté, T de l’autre) : le
système est bloc-diagonal. Un vrai couplage — une diffusivité fonction de la
température, ou une thermodiffusion — se pilote depuis Python, en réassemblant la
partie diffusive à chaque pas avec un champ matériau recalculé.
Transfert à travers une interface
Deux corps qui se touchent ne partagent pas forcément leurs nœuds. Un contact imparfait, un revêtement, un joint, une membrane laissent le champ sauter à la traversée, tandis qu’un flux la franchit proportionnellement à ce saut :
\[ j\cdot n = h\,\big(c_1 - c_2\big) \]
h_c_H2 est le coefficient de transfert (son inverse est la résistance de
contact) : un par grandeur transférée, nommé d’après elle.
Ce modèle n’a rien de diffusif non plus : on lui passe
[("T", "q")]et une conduction pour cible pour une résistance de contact, les couples de déplacement et une élasticité pour un joint collé de raideur finie — la nature vient de la cible. La loi commune, sa structure en quatre blocs dont deux hors-diagonale, l’exigence de conformité des deux côtés et le critère qui départage un échange d’une contrainte MPC sont dans Échanges.
corps = pyrucast.model.fick(gauche, "H2") | pyrucast.model.fick(droite, "H2")
model = corps | pyrucast.model.interface_transfer(
face_gauche, face_droite, corps, [("c_H2", "j_H2")]
)
materials = pyrucast.element_field.material_field(
model, [("D_H2", 2.0), ("h_c_H2", 5.0)]
)
Ce que ça vaut comme vérification
Deux carrés côte à côte, un flux q injecté d’un côté, la concentration imposée
de l’autre : le profil est linéaire par morceaux avec une chute q/D dans chaque
carré et un saut q/h à l’interface. C’est ce saut qui distingue une
interface d’un nœud partagé, et il est porté entièrement par les blocs
hors-diagonale. Quand h → ∞, le saut s’efface et l’on retrouve le corps
continu.
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::Physics;
use pyrucast::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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(())
}
Régime transitoire
La matrice de stockage s’assemble avec matrix.mass(...), qui exige alors la
composante poro. L’intégration en temps est orchestrée en Python, comme pour la
thermique transitoire — le noyau Rust fournit K et C, pas la boucle.
Bilan de matière
Comme en thermique, le multiplicateur de Lagrange d’une concentration imposée est le flux d’espèce qui traverse la frontière. C’est la vérification la plus directe d’un calcul de diffusion, et c’est ce que contrôle le test d’intégration ci-dessus : la réaction au bord imposé égale exactement le flux injecté à l’autre bout.
Échanges (frontière et interface)
Introduction
Deux physiques, une seule loi. Un échange fait traverser un flux proportionnellement à un écart :
\[ q\cdot n = h\,\big(a - b\big) \]
et toute la différence entre les deux tient à de quel côté du signe égal vit le milieu d’en face :
| modèle | l’autre côté est… | où il va |
|---|---|---|
boundary_transfer | une donnée (une valeur ambiante, a_ext_<primale>) | au second membre, \( h\,a_\text{ext}\int N\,d\Gamma \), rendu par external_forces |
interface_transfer | une inconnue (le champ de l’autre maillage) | dans la matrice, en bloc de couplage |
Le bloc hors-diagonale est le second membre rendu implicite. C’est pourquoi les
deux partagent leur noyau (src/models/transfer.rs) au lieu d’en porter chacun
une copie : le terme de frontière est le terme d’interface avec les deux côtés
sur la même maille.
Ce qui est transféré appartient à l’appelant
Aucun des deux ne sait ce qu’il transporte. On lui donne une liste de couples
(primale, duale) — la forme que prennent déjà
embedded et contact, les
deux autres lois qui lient des maillages — et tout le reste en découle :
| transféré | matériau | entrée du comportement | sortie |
|---|---|---|---|
("T", "q") | h_T | T / jump_T | flux_T |
("c_H2", "j_H2") | h_c_H2 | c_H2 / jump_c_H2 | flux_c_H2 |
("u_x", "f_x") | h_u_x | u_x / jump_u_x | flux_u_x |
Un coefficient par grandeur transférée, nommé d’après elle. C’est ce qui
permet une raideur par direction, et ce qu’un h unique ne pouvait pas exprimer.
Nommer les DDL de la physique de volume est aussi ce qui fait que le terme s’y
couple directement : un boundary_transfer sur ("T", "q") entre dans la
raideur d’un heat_conduction sans adaptateur, parce que la
matrice est indexée par le couple (nœud, nom de champ).
La cible donne la nature
Des noms de variables libres ne disent pas quelle nature physique porte
l’échange ("thermal", "diffusion", "mechanical") — c’est pourtant elle que
model.filter(...) sélectionne. Le modèle dont l’échange nomme les DDL, lui, la
connaît. Les deux lois se construisent donc contre ce modèle, passé en
argument target après le support, exactement comme une
charge répartie ou une contrainte :
conduction = model.heat_conduction(volume)
film = model.boundary_transfer(peau, conduction, [("T", "q")]) # thermique
corps = model.fick(gauche, "H2") | model.fick(droite, "H2")
joint = model.interface_transfer(face_g, face_d, corps, [("c_H2", "j_H2")]) # diffusion
Chaque couple doit être assemblé par la cible : un de ses sous-modèles compte la primale parmi ses inconnues et l’apparie à la duale. La vérification se fait une fois, à la construction, et refuse trois erreurs qui passaient jusqu’ici :
| erreur | ce qu’elle bâtissait |
|---|---|
| un nom mal tapé, ou une cible qui n’assemble pas ce couple | un échange couplé à rien : une matrice sur des lignes qu’aucune physique ne résout |
une primale appariée à la duale d’une autre (("u_x", "f_y")) | un film qui écrit dans la mauvaise équation |
| des couples de deux natures dans un seul échange | un terme que filter rangerait d’un seul côté |
Pour deux natures, on construit deux échanges. Le rayonnement
reçoit lui aussi sa cible — il écrit dans la ligne q d’une conduction —, mais
ses natures restent les siennes, [Thermal, Radiation] : la cible n’y sert qu’à
prouver que la ligne est assemblée.
Forme discrétisée
Échange avec une ambiante
La forme faible du terme de bord se scinde en deux ingrédients, dont le sous-modèle ne porte que le premier :
\[ \underbrace{K_{ij} = h \int_\Gamma N_i\,N_j\,d\Gamma}{\text{matrice de film (raideur)}} \qquad \underbrace{f_i = h\,a\text{ext} \int_\Gamma N_i\,d\Gamma}_{\text{charge (second membre)}} \]
La part ambiante est un chargement, et le sous-modèle le porte : a_ext est
une composante matériau exigée, à côté de son coefficient, et le terme se
récupère par external_forces. Elle a longtemps
été bâtie à la main avec l’opérateur flux, ce qui laissait l’oublier — et un
ambiant oublié ne se voit pas : il se lit comme un ambiant nul, donc comme une
paroi qui échange avec le zéro absolu. Le terme de film rend par ailleurs la
matrice définie : un problème purement Neumann + échange est bien posé
sans Dirichlet.
Aucune normale à choisir. Elle est déjà consommée en passant de \( q\cdot n \) à \( h(a - a_\text{ext}) \) ; ce qui reste sous l’intégrale est un scalaire fois la mesure \( d\Gamma = \sqrt{\det(J^\top J)} \), une magnitude indépendante de l’orientation du maillage de bord — contrairement à une pression ou à un flux signé.
Échange entre deux maillages
Ni l’une ni l’autre n’a de loi de comportement. Le h·a d’un bord et le
h·(a₁−a₂) d’une interface sont le coefficient de leur propre opérateur
appliqué en un point, pas un comportement : le même h a déjà bâti ∫h NᵀN.
Elles ont longtemps dû s’en inventer une, parce que Domain en exigeait une ;
depuis que la loi est une capacité à part, elles n’en déclarent plus.
Côté résidu, l’intégrale d’une différence est la différence des intégrales :
l’interface rend ∫h(a₁−a₂)N sur A et son opposé sur B, chacune dispersée sur
son propre espace. Ce qui est couplé, c’est la matrice — lignes sur A,
colonnes sur B — pas le vecteur, qui ne produit qu’un nombre par nœud. La
conformité exigée des deux côtés sert à savoir où lire a₂ : maille pour
maille, les deux interpolations s’alignent indice pour indice et le saut est une
soustraction. Sans elle il faudrait localiser les points de Gauss de l’un dans
l’autre — ce qui n’est plus la même méthode, mais un mortar.
Quand l’autre côté est une inconnue, la forme faible devient
\[ \int_\Gamma h\,(a_1 - a_2)\,(\delta a_1 - \delta a_2)\; d\Gamma, \]
qui se développe en une structure \( 2\times2 \) sur les DDL des deux côtés :
\[ \begin{bmatrix} +K & -K \\ -K & +K \end{bmatrix}, \qquad K_{ij} = h \int_\Gamma N_i\,N_j\; d\Gamma . \]
Les deux blocs diagonaux sont exactement deux termes de frontière, un par
côté. Les deux autres ont leurs lignes sur un maillage et leurs colonnes sur
l’autre : c’est le genre de contribution Coupling, dont ce modèle est le seul
utilisateur (voir
Ajouter une physique).
Les quatre sortent d’une seule fonction, transfer::exchange_matrix : les
diagonaux avec la même maille des deux côtés et le signe +, les croisés avec la
maille en vis-à-vis et le signe −. Le signe est donc porté par le noyau et non
par un facteur qu’il faudrait faire circuler dans l’assembleur. Chaque bloc de
couplage pris seul est non symétrique ; leur réunion l’est — exactement comme
la paire C / Cᵀ de Dirichlet.
Les grandeurs transférées ne se couplent pas entre elles : la chaleur qui traverse un joint n’y pousse pas l’hydrogène. Seule la diagonale en indice de variable est écrite, donc le coût reste linéaire en nombre de composantes.
Conformité
Les deux côtés doivent être conformes : même type d’élément, même nombre de
mailles, maille i face à maille i, et nœud local k face au nœud local k.
C’est vérifié géométriquement à la construction — les nœuds appariés doivent être
colocalisés — et signalé plutôt qu’approché. Une interface non conforme est un
problème de maillage ; la rattraper par une projection silencieuse fabriquerait
des flux faux sans le dire.
Les deux maillages sont donc géométriquement confondus mais numérotés séparément : c’est cette duplication des nœuds qui laisse le champ sauter, là où un nœud partagé l’interdirait.
Mise en donnée (Rust, testé)
/// 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(())
}
Exemple Python
# Thermal film: enters the stiffness of the conduction it cools, and
# en prend la nature.
conduction = pyrucast.model.heat_conduction(fes)
model = conduction | pyrucast.model.boundary_transfer(peau, conduction, [("T", "q")])
materials = pyrucast.element_field.material_field(
model, [("k", 5.0), ("h_T", 12.0), ("a_ext_T", 20.0)]
)
# Contact resistance between two meshes.
corps = pyrucast.model.heat_conduction(gauche) | pyrucast.model.heat_conduction(droite)
joint = pyrucast.model.interface_transfer(face_gauche, face_droite, corps, [("T", "q")])
# Elastic foundation: the same law, on displacements.
plaque = pyrucast.model.elasticity(fes, "plane_stress")
appui = pyrucast.model.boundary_transfer(
semelle, plaque, [("u_x", "f_x"), ("u_y", "f_y")]
)
# No kind in argument: each took its target's own.
joint[0].physics() # ["thermal"]
appui[0].physics() # ["mechanical"]
Compléments
Ce que la généralisation apporte
Rien dans la loi n’est thermique ni diffusif, donc deux modèles sortent sans une ligne de mécanique :
- une fondation élastique de Winkler — un échange de frontière sur les
déplacements. Une barre poussée sur sa face libre, cette face reposant sur un
ressort réparti, c’est la barre et le ressort en parallèle sous la traction
appliquée : \( u = q / (E/L + h) \), ce que le test vérifie à
1e-9sur quatre décades de raideur ; - un joint collé de raideur finie — le même sur une interface.
Échange ou contrainte ?
Un interface_transfer est la régularisation par pénalité de ce qu’un
MPC impose exactement :
mpc([(T, maillage₁, +1), (T, maillage₂, −1)]) = 0 ⟺ T₁ = T₂, exactement
interface_transfer(maillage₁, maillage₂), h → ∞ ⟶ T₁ = T₂, à 1/h près
mpc | interface_transfer | |
|---|---|---|
h | n’existe pas — un multiplicateur n’est pas un matériau | une constante physique |
| inconnues | ajoute lambda_mpc | aucune |
| système | point-selle, indéfini | reste défini positif |
| conditionnement | insensible | se dégrade quand h monte |
Le critère de choix tient en une phrase : si h vient d’une mesure, c’est de la
physique ; s’il a été choisi « assez grand », il fallait une contrainte. Un
joint imparfait, une résistance thermique de contact, une couche adhésive ont un
h mesurable ; « lier deux surfaces » n’en a pas.
Ce que ça vaut comme vérification
Le film thermique est confronté à une solution analytique — une dalle chauffée d’un côté, refroidie de l’autre, dont tout le flux ressort par convection (voir Conduction thermique).
L’interface l’est au saut : deux carrés côte à côte, un flux q injecté d’un
côté, la concentration imposée de l’autre, et un saut q/h à la traversée (voir
Diffusion). C’est ce saut qui
distingue une interface d’un nœud partagé, et il est porté entièrement par les
blocs hors-diagonale ; quand h → ∞ il s’efface et l’on retrouve le corps
continu.
S’y ajoute la structure : la raideur assemblée reste symétrique alors qu’aucun bloc de couplage ne l’est, ce qui est la vérification que les quatre blocs atterrissent où ils doivent.
Ce qui n’est pas couvert
Pas d’échange entre maillages non coïncidents : la liaison exacte a sa
version non conforme avec embedded et ses poids
d’interpolation, l’échange fini n’en a pas. Le mécanisme existe, il n’a
simplement jamais été branché là.
Mécanique
Les physiques mécaniques, chacune décrite sur sa page : équations résolues, exemple de mise en donnée Rust testé, et exemple Python.
Comme pour la thermique, ce sont des variantes de
SubModel : chacune déclare ses variables, son matériau, et
assemble sa rigidité K (et, le cas échéant, son comportement COMP).
Convention de nommage : primal = déplacements u_x, u_y, u_z (les
inconnues) ; dual = forces nodales f_x, f_y, f_z (second membre /
réactions).
- Barre / treillis — élément à effort axial (1-D/2-D/3-D).
- Élasticité linéaire — continuum 2-D (CP/DP) et 3-D.
- Élasticité orthotrope et anisotrope — la symétrie matériau, repère donné par vecteurs.
- Plasticité parfaite (von Mises) — J2 sans
écrouissage, retour radial (état interne
εᵖ,p). - Lois d’écoulement plastique — la loi comme attribut : écrouissage isotrope, Drucker-Prager, Ottosen.
- Fluage et viscoplasticité — les lois dépendantes du
temps, qui exigent
dt. - Endommagement de Mazars — endommagement isotrope du
béton, deux variables (état interne
κ). - Lois d’endommagement — la loi comme attribut : Damage TC, SiC/SiC orthotrope, Gurson.
- Poutre d’Euler-Bernoulli — sans cisaillement transverse, interpolation d’Hermite (1-D / plan / spatial).
- Poutre de Timoshenko — flexion + cisaillement, intégration réduite (anti-verrouillage).
- Portique 2D — poutre orientée (axial + flexion + cisaillement), transformation local→global.
- Coques — surface à six DDL par nœud, Reissner-Mindlin à cisaillement sous-intégré ou Kirchhoff discret (DKT/DKQ) sans cisaillement.
- Cadre 3D — space frame 6 DOF/nœud (axial + torsion + flexion 2 plans), orientation automatique.
Barre / treillis
Élément SEG2 à 2 nœuds transmettant uniquement l’effort axial (treillis).
Fonctionne à l’identique en 1-D, 2-D et 3-D.
Équations continues résolues
La barre suit la loi axiale 1-D et son équilibre le long de l’axe s :
\[ N = E\,A\,\varepsilon, \qquad \varepsilon = \frac{du}{ds}, \qquad \frac{dN}{ds} + f = 0, \]
où N est l’effort normal, ε la déformation axiale (dérivée du déplacement
le long de la barre) et f la charge axiale répartie. L’orientation est
déduite des coordonnées des nœuds via le cosinus directeur c = (x_B − x_A)/L.
Forme discrétisée
Avec l’interpolation linéaire SEG2, la déformation est constante par
élément. Projetée sur les directions physiques par c, la rigidité élémentaire
globale (en d dimensions) s’écrit
\[ K_e = \frac{E\,A}{L} \begin{bmatrix} c\,c^\top & -c\,c^\top \\ -c\,c^\top & c\,c^\top \end{bmatrix}, \]
écrite aux positions (NodeId_i, f_a) × (NodeId_j, u_b). En 1-D, c = 1 et l’on
retrouve (EA/L)[[1,−1],[−1,1]].
Variables et matériau
- primal :
u_x, u_y(, u_z)— dual :f_x, f_y(, f_z). - matériau :
E(module d’Young),A(section) ;rhofacultatif (masse). - comportement (
COMP) : effort axialN = E·A·(cᵀ ε c), à partir de la déformationε(opdeformation).
⚠️ Une barre n’a aucune raideur transversale : pour un système bien posé il faut bloquer les DOFs transverses (treillis triangulé, appuis), sinon la matrice est singulière.
Mise en donnée (Rust, testé)
Barre horizontale encastrée à gauche, appuyée transversalement à droite, force
axiale F au bout ⇒ u_x = F·L/(E·A). Le code est le test d’intégration
tests/truss.rs, exécuté à chaque cargo test :
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::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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))?);
let multiplier = mesh::barycenter(&imposed)?;
model::dirichlet(target, var, &imposed, &multiplier, Default::default())
};
let mut model = model::truss(&fes)?;
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(())
}
Exemple Python
"""Bar / truss — a bar in tension, compared with the analytical solution.
Physique
--------
A 2-node `SEG2` element transmitting the axial force only. Law: `N = E·A·ε`
with `ε = du/ds` (axial strain along the bar). Global stiffness
`K_e = (E·A/L)·[[c⊗c, -c⊗c], [-c⊗c, c⊗c]]`, where `c` is the direction cosine
(derived from the nodes' coordinates) — works in 1-D/2-D/3-D.
Problème
--------
A horizontal bar of length `L`, clamped on the left (`u_x = u_y = 0`),
transversally supported on the right (`u_y = 0`), axial force `F` on the right.
A bar having no transverse stiffness, `u_y` is blocked at both nodes.
Solution analytique : `u_x = F·L / (E·A)`.
Lancement ::
maturin develop --features extension-module
python examples/truss.py
"""
import pyrucast
E, A, L, F = 210.0e9, 1.0e-4, 2.0, 1000.0
def _clamp(target, node, var):
"""Homogeneous Dirichlet (u = 0) on `var` at node `node`."""
imposed = pyrucast.mesh.poi1_from_nodes([node])
multiplier = pyrucast.mesh.barycenter(imposed)
return pyrucast.model.dirichlet(target, var, imposed, multiplier)
def main() -> None:
c = pyrucast.Coords(2)
n0 = c.add_node([0.0, 0.0])
n1 = c.add_node([L, 0.0])
mesh = pyrucast.mesh.line(n0, n1, 1) # un seul SEG2 (mailleur `line`)
fes = pyrucast.FiniteElementSpace(mesh)
model = pyrucast.model.truss(fes)
model = model | _clamp(model, n0, "u_x")
model = model | _clamp(model, n0, "u_y")
model = model | _clamp(model, n1, "u_y") # no transverse stiffness
materials = pyrucast.element_field.material_field(model, [("E", E), ("A", A)])
load = pyrucast.mesh.poi1_from_nodes([n1])
rhs = pyrucast.NodeField(load, ["f_x"])
rhs[0].set_value(n1, "f_x", F)
solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), rhs)
ux = solution.value(n1, "u_x")
expected = F * L / (E * A)
print(f"u_x (bout) = {ux:.6e} (analytique F·L/E·A = {expected:.6e})")
assert abs(ux - expected) < 1e-10 * expected
print("OK : élongation axiale conforme à F·L/(E·A).")
if __name__ == "__main__":
main()
Compléments
Masse & rigidité géométrique
En plus de la rigidité, la barre assemble :
- masse consistante
M = (ρAL/6)[[2,1],[1,2]]sur chaque composante de translation (rhocomposante matériau optionnelle) —pyrucast.matrix.mass; - rigidité géométrique
K_g = (N/L)·(I − c⊗c)transverse, sous l’effort axialN(sortiendu comportement) —pyrucast.matrix.geometric.
Élasticité linéaire
Continuum en petites déformations : 2-D (TRI3 / QUA4) ou 3-D
(TET4 / HEX8), les cas 2-D couvrant aussi l’axisymétrie (solide de
révolution maillé dans son plan méridien).
Équations continues résolues
Sur le domaine \( \Omega \), avec \( b \) les efforts volumiques :
\[ \underbrace{\nabla\cdot\sigma + b = 0}{\text{équilibre}}, \qquad \underbrace{\sigma = \mathbb{D} : \varepsilon}{\text{loi de Hooke}}, \qquad \underbrace{\varepsilon = \tfrac12(\nabla u + \nabla u^\top)}_{\text{cinématique}}. \]
La forme faible (multiplication par un déplacement virtuel \( v \), intégration par parties) s’écrit : trouver \( u \) tel que pour tout \( v \),
\[ \int_\Omega \varepsilon(v) : \mathbb{D} : \varepsilon(u)\,d\Omega = \int_\Omega v\cdot b\,d\Omega + \int_{\Gamma_N} v\cdot t\,d\Gamma, \]
où \( t \) est la traction imposée sur le bord de Neumann \( \Gamma_N \).
Forme discrétisée
En convention de Voigt (déformation ingénieur \( \gamma = 2\varepsilon \)),
le champ discret \( u_h = \sum_i N_i u_i \) donne \( \varepsilon = B\,u_e \),
avec la matrice déformation-déplacement \( B \) bâtie des dérivées
physiques \( \partial N_i/\partial x_a \) (voir
dn_dx). En 2-D
(\( \varepsilon = [\varepsilon_{xx}, \varepsilon_{yy}, \gamma_{xy}]^\top \)),
le bloc du nœud \( i \) est
\[ B_i = \begin{bmatrix} \partial_x N_i & 0 \\ 0 & \partial_y N_i \\ \partial_y N_i & \partial_x N_i \end{bmatrix}, \]
et en 3-D (\( \varepsilon = [\varepsilon_{xx}, \varepsilon_{yy}, \varepsilon_{zz}, \gamma_{yz}, \gamma_{xz}, \gamma_{xy}]^\top \)),
\[ B_i = \begin{bmatrix} \partial_x N_i & 0 & 0 \\ 0 & \partial_y N_i & 0 \\ 0 & 0 & \partial_z N_i \\ 0 & \partial_z N_i & \partial_y N_i \\ \partial_z N_i & 0 & \partial_x N_i \\ \partial_y N_i & \partial_x N_i & 0 \end{bmatrix}. \]
La rigidité élémentaire est alors, intégrée par quadrature de Gauss,
\[ K_e = \int_{\Omega_e} B^\top D\, B\, d\Omega \;\approx\; \sum_g B(\xi_g)^\top D\, B(\xi_g)\,|J(\xi_g)|\,w_g, \]
écrite aux positions (NodeId_i, f_a) × (NodeId_j, u_b) (ordre des DOFs
nœud-majeur). Le second membre nodal cohérent d’une traction de bord est
\( f_i = \int_{\Gamma_N} N_i\,t\,d\Gamma \) (opérateur
flux).
Matrice constitutive D
Le modèle fixe \( D \) (isotrope, module d’Young \( E \), coefficient de Poisson \( \nu \)) :
plane_stress(contraintes planes, \( \sigma_{zz}=0 \)), avec \( c = \dfrac{E}{1-\nu^2} \) :
\[ D = c\begin{bmatrix} 1 & \nu & 0 \\ \nu & 1 & 0 \\ 0 & 0 & \tfrac{1-\nu}{2} \end{bmatrix}; \]
plane_strain(déformations planes, \( \varepsilon_{zz}=0 \), \( \sigma_{zz}\neq 0 \)), avec \( c = \dfrac{E}{(1+\nu)(1-2\nu)} \) :
\[ D = c\begin{bmatrix} 1-\nu & \nu & 0 \\ \nu & 1-\nu & 0 \\ 0 & 0 & \tfrac{1-2\nu}{2} \end{bmatrix}; \]
full_3d(3-D), même \( c \), avec le module de cisaillement \( G = c\,\tfrac{1-2\nu}{2} \) :
\[ D = \begin{bmatrix} c(1-\nu) & c\nu & c\nu & & & \\ c\nu & c(1-\nu) & c\nu & & & \\ c\nu & c\nu & c(1-\nu) & & & \\ & & & G & & \\ & & & & G & \\ & & & & & G \end{bmatrix} \quad (\text{ordre } [xx, yy, zz, yz, xz, xy]). \]
axisymmetric(solide de révolution), même \( c \) : les trois directions normales \( r, z, \theta \) étant orthogonales, le bloc normal est l’isotrope 3×3 et \( rz \) est le seul cisaillement,
\[ D = c\begin{bmatrix} 1-\nu & \nu & \nu & \\ \nu & 1-\nu & \nu & \\ \nu & \nu & 1-\nu & \\ & & & \tfrac{1-2\nu}{2} \end{bmatrix} \quad (\text{ordre } [rr, zz, \theta\theta, rz]). \]
Axisymétrie
Un solide de révolution se maille dans son plan méridien \( (r, z) \) sur
des Coords déclarées axisymétriques (\( x = r \ge 0 \), \( y = z \)) — voir
Coordonnées. Deux choses changent, et elles
ont deux origines distinctes :
- la mesure d’intégration, portée par la géométrie :
\( d\Omega = 2\pi r\,|J|\,d\xi \). Elle vaut pour toutes les intégrales
— rigidité, masse, conductivité, flux réparti, volumes, forces internes, y
compris sur les sous-maillages de bord
SEG2, dont \( \int 2\pi r\,N \) donne directement l’effort sur l’anneau. Rien à écrire : c’estCellGeom::det_j_wqui l’applique, en un seul point ; - la déformation orthoradiale, portée par le modèle : \( \varepsilon_{\theta\theta} = u_r / r \), que le gradient méridien ne peut pas exprimer. Elle ajoute une quatrième composante de Voigt et une ligne à \( B \) :
\[ B_i = \begin{bmatrix} \partial_r N_i & 0 \\ 0 & \partial_z N_i \\ N_i / r & 0 \\ \partial_z N_i & \partial_r N_i \end{bmatrix} \quad (\varepsilon = [\varepsilon_{rr}, \varepsilon_{zz}, \varepsilon_{\theta\theta}, \gamma_{rz}]^\top). \]
Les points de Gauss étant intérieurs à la maille, \( r > 0 \) même pour un élément qui touche l’axe : le terme \( N_i/r \) reste fini, sans traitement particulier de l’axe.
Nommage (convention Cast3M) : les composantes s’appellent sigma_xx,
sigma_yy, sigma_zz, sigma_xy et eps_xx, eps_yy, eps_zz, eps_xy,
où zz désigne l’orthoradial \( \theta\theta \) — le plan méridien
n’occupant que xx, yy et xy, il n’y a pas de collision.
Le modèle et le repère doivent s’accorder dans les deux sens : une géométrie
de révolution refuse plane_stress / plane_strain, et axisymmetric refuse
une géométrie cartésienne. Sans cela on mélangerait silencieusement une loi plane
avec la mesure \( 2\pi r \).
La thermique n’a rien de spécifique à faire : le flux \( q = -k\nabla T \) est déjà purement méridien, et le facteur \( 2\pi r \) suffit à produire le profil logarithmique d’un cylindre creux. La plasticité et Mazars supportent l’axisymétrie, leur état interne étant déjà stocké en 3-D complet. En revanche barre et portique la refusent : un segment du plan méridien engendre une coque de révolution, que leurs noyaux ne modélisent pas.
Un maillage de bord (SEG2 en 2-D) est par ailleurs refusé comme domaine
par les trois physiques de milieu continu : B y serait bâti sur le gradient
tangent et \( B^\top D B \) serait déficient en rang dans la direction
normale. Un bord porte des charges (flux, convection), il n’est pas un massif.
Validation : tests/axisymmetric.rs (Lamé, patch test de dilatation uniforme,
\( \int B^\top\sigma = K u \), volume et masse de révolution, conduction
logarithmique) et tests/python/test_axisymmetric.py.
Convergence sur Lamé
La solution de Lamé \( u_r = c_1 r + c_2/r \) comporte un terme rationnel : aucune base de Lagrange, de quelque degré que ce soit, ne la reproduit exactement. Les éléments quadratiques gagnent un ordre, pas l’exactitude. La solution ne dépendant que de \( r \), le problème discret est une EDO 1-D et les valeurs nodales sont superconvergentes en \( O(h^{2p}) \) :
nr | Q1 (QUA4) | ordre | Q2 (QUA8) | ordre |
|---|---|---|---|---|
| 5 | 6,5e-3 | — | 2,6e-5 | — |
| 10 | 1,6e-3 | 1,97 | 1,7e-6 | 3,94 |
| 20 | 4,1e-4 | 1,99 | 1,1e-7 | 3,99 |
| 40 | 1,0e-4 | 2,00 | 6,8e-9 | 4,00 |
(erreur relative maximale sur \( u_r \)). Les contraintes, une dérivée plus bas, passent de \( O(h) \) à \( O(h^2) \).
Le cas exact existe néanmoins : lorsque \( c_2 = 0 \) — dilatation uniforme \( u_r = c\,r \) — l’état de déformation est constant et même Q1 le reproduit à la précision machine (c’est le patch test de la suite de validation).
Matrice de masse
Pour la dynamique, la masse consistante (composante matériau rho) est
\[ M_e = \int_{\Omega_e} \rho\,N^\top N\, d\Omega \;\approx\; \sum_g \rho\,N(\xi_g)^\top N(\xi_g)\,|J(\xi_g)|\,w_g, \]
où \( N \) place \( N_i \) sur chaque composante de translation — assemblée
par assemble.mass, et concentrable en diagonale
par lump.
Variables et matériau
- primal :
u_x, u_y(, u_z)— dual :f_x, f_y(, f_z). - matériau :
E(Young),nu(Poisson) ; facultatifalpha(dilatation thermique, cf. thermomécanique),rho(masse) — accepté par le champ matériau mais jamais exigé pour un assemblage purement élastique. - comportement (
COMP) :σ = D ε(convention tenseur → ingénieurγ = 2ε), à partir de la déformationε(opdeformation). - modèles :
plane_stress,plane_strain,axisymmetric(2-D) etfull_3d(3-D).
Mise en donnée (Rust, testé)
Carré unité en contraintes planes : appuis u_x = 0 (gauche) et u_y = 0
(bas), traction S sur le bord droit appliquée en charges nodales cohérentes
par l’opérateur flux (composante f_x).
Solution exacte u_x = (S/E)·x, u_y = −(ν S/E)·y. Code = test
tests/elasticity.rs (le fichier contient aussi un test 3-D sur un cube
HEX8) :
use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::finite_element_space::FiniteElementSpace;
use pyrucast::containers::mesh::{Mesh, SubMesh};
use pyrucast::containers::model::Model;
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::solve;
use pyrucast::Result;
#[test]
fn elasticity_unit_square_uniaxial_tension() -> Result<()> {
const E: f64 = 210.0; // Young's modulus
const NU: f64 = 0.3; // Poisson's ratio
const S: f64 = 2.0; // traction on the right edge
const N: usize = 2; // N×N QUA4 grid
let h = 1.0 / N as f64;
// ── QUA4 mesh on [0,1]² ────────────────────────────────────────────────
let coords = Handle::new(Coords::new(2)?);
let idx = |i: usize, j: usize| j * (N + 1) + i;
let mut grid: Vec<Node> = Vec::new();
for j in 0..=N {
for i in 0..=N {
grid.push(Node::create_in(
coords.clone(),
&[i as f64 * h, j as f64 * h],
)?);
}
}
let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::QUA4));
for j in 0..N {
for i in 0..N {
mesh.add_cell(&[
grid[idx(i, j)].id(),
grid[idx(i + 1, j)].id(),
grid[idx(i + 1, j + 1)].id(),
grid[idx(i, j + 1)].id(),
])?;
}
}
let fes = FiniteElementSpace::lagrange1(&mesh)?;
// ── Modèle : élasticité plane stress + appuis (rollers) ────────────────
// The support receives the model it constrains: the dual is read there, and
// contraints s'y vérifient.
let roller = |target: &Model, nodes: &[Node], var: &str| -> Result<Model> {
let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(nodes)?);
let multiplier = mesh::barycenter(&imposed)?;
model::dirichlet(target, var, &imposed, &multiplier, Default::default())
};
let left: Vec<Node> = (0..=N).map(|j| grid[idx(0, j)].clone()).collect();
let bottom: Vec<Node> = (0..=N).map(|i| grid[idx(i, 0)].clone()).collect();
let mut model = model::elasticity(&fes, Kinematics::PlaneStress)?;
model = model.union(&roller(&model, &left, "u_x")?)?;
model = model.union(&roller(&model, &bottom, "u_y")?)?;
// ── Loading: traction S on the right edge (consistent nodal loads, on the
// f_x component). The load is a sub-model: it joins the model, its density
// the material. ──────────────────────────────────────────────────────
let mut right_edge = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::SEG2));
for j in 0..N {
right_edge.add_cell(&[grid[idx(N, j)].id(), grid[idx(N, j + 1)].id()])?;
}
let right_fes = FiniteElementSpace::lagrange1(&right_edge)?;
model = model.union(&model::flux(&right_fes, &model, "f_x".into())?)?;
let materials = pyrucast::ops::element_field::material_field(
&model,
&[("E", E), ("nu", NU), ("phi_f_x", S)],
)?;
let rhs = pyrucast::ops::node_field::external_forces(&model, &materials)?;
// ── Assemblage + résolution ────────────────────────────────────────────
let stiffness = pyrucast::ops::matrix::stiffness(&model, &materials)?;
let solution = solve(&stiffness, &rhs)?;
// ── Compared with the analytical u_x = (S/E)·x, u_y = −(ν S/E)·y ───────
let tol = 1e-10;
for j in 0..=N {
for i in 0..=N {
let (x, y) = (i as f64 * h, j as f64 * h);
let ux = solution.value(grid[idx(i, j)].id(), "u_x")?;
let uy = solution.value(grid[idx(i, j)].id(), "u_y")?;
assert!((ux - S / E * x).abs() < tol, "u_x({x},{y}) = {ux}");
assert!((uy + NU * S / E * y).abs() < tol, "u_y({x},{y}) = {uy}");
}
}
Ok(())
}
Exemple Python
"""Élasticité linéaire — traction d'un carré (contraintes planes).
Physique
--------
Continuum en petites déformations : équilibre `∇·σ = 0`, loi de Hooke
`σ = D : ε`, kinematics `ε = ½(∇u + ∇uᵀ)`. The stiffness is
`K = ∫ Bᵀ D B dΩ` (B : matrice déformation-déplacement en Voigt, D : matrice
constitutive isotrope, ici en contraintes planes).
Problème
--------
Carré unité, appuis `u_x = 0` (bord gauche) et `u_y = 0` (bord bas), traction
`S` on the right edge applied as consistent nodal loads by the `flux` operator
(on the `f_x` component). Exact (uniaxial) solution:
`u_x = (S/E)·x`, `u_y = -(ν·S/E)·y`.
Lancement ::
maturin develop --features extension-module
python examples/elasticity.py
"""
import pyrucast
E, NU, S, N = 210.0, 0.3, 2.0, 2
def _clamp(target, nodes, var):
imposed = pyrucast.mesh.poi1_from_nodes(nodes)
multiplier = pyrucast.mesh.barycenter(imposed)
return pyrucast.model.dirichlet(target, var, imposed, multiplier)
def main() -> None:
h = 1.0 / N
c = pyrucast.Coords(2)
def idx(i, j):
return j * (N + 1) + i
# An N×N grid of QUA4 by sweeping two SEG2 lines (`sweep`).
bottom = pyrucast.mesh.line(c.add_node([0.0, 0.0]), c.add_node([1.0, 0.0]), N)
top = pyrucast.mesh.line(c.add_node([0.0, 1.0]), c.add_node([1.0, 1.0]), N)
mesh = pyrucast.mesh.sweep(bottom, top, N)
# Nodes laid out by idx(i, j) (i along x, j along y) by reading the
# connectivité QUA4 : maille (cy, cx) = cy*N + cx, nœuds locaux 0..3.
grid = [None] * ((N + 1) * (N + 1))
for cy in range(N):
for cx in range(N):
cell = cy * N + cx
grid[idx(cx, cy)] = mesh.node(0, cell, 0)
grid[idx(cx + 1, cy)] = mesh.node(0, cell, 1)
grid[idx(cx + 1, cy + 1)] = mesh.node(0, cell, 2)
grid[idx(cx, cy + 1)] = mesh.node(0, cell, 3)
fes = pyrucast.FiniteElementSpace(mesh)
left = [grid[idx(0, j)] for j in range(N + 1)]
bottom = [grid[idx(i, 0)] for i in range(N + 1)]
model = pyrucast.model.elasticity(fes, "plane_stress")
model = model | _clamp(model, left, "u_x")
model = model | _clamp(model, bottom, "u_y")
# Traction S on the right edge → consistent nodal loads (the flux op).
right = pyrucast.Mesh(c, "SEG2")
for j in range(N):
right.unit().add_cell([grid[idx(N, j)], grid[idx(N, j + 1)]])
right_fes = pyrucast.FiniteElementSpace(right)
model = model | pyrucast.model.flux(right_fes, model, "f_x")
materials = pyrucast.element_field.material_field(
model, [("E", E), ("nu", NU), ("phi_f_x", S)]
)
rhs = pyrucast.node_field.external_forces(model, materials)
solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), rhs)
print(f"{'x':>5} {'y':>5} {'u_x':>12} {'u_y':>12}")
tol = 1e-10
for j in range(N + 1):
for i in range(N + 1):
x, y = i * h, j * h
ux = solution.value(grid[idx(i, j)], "u_x")
uy = solution.value(grid[idx(i, j)], "u_y")
print(f"{x:5.2f} {y:5.2f} {ux:12.6e} {uy:12.6e}")
assert abs(ux - S / E * x) < tol
assert abs(uy + NU * S / E * y) < tol
print("\nOK: uniaxial field matching u_x=(S/E)x, u_y=-(νS/E)y.")
if __name__ == "__main__":
main()
Compléments
Thermomécanique non couplée
Première brique de thermomécanique : une température imposée ΔT engendre une
déformation thermique de libre dilatation ε_th = α·(T − T_ref), d’où des
contraintes mécaniques — sans rétroaction de la mécanique sur le thermique.
En petites déformations, la rigidité K reste l’élastique ; le terme thermique
n’agit que sur le second membre et sur la contrainte réelle :
\[ \sigma = D : (\varepsilon(u) - \varepsilon_{th}), \qquad f_{th} = \int_\Omega B^\top D\, \varepsilon_{th}\, d\Omega. \]
Aucune physique nouvelle : on compose les briques existantes. alpha est fourni
au champ matériau (composante facultative) ; la température, portée aux points de
Gauss par interp_to_gauss, alimente
thermal_strain (EPTH) ; la charge thermique sort
de integrate_behavior + internal_forces (BSIG) ; enfin la contrainte réelle
se relit sur deformation(u) − ε_th.
Deux régimes sur une barre chauffée valident les fermetures analytiques : bord en
x encastré aux deux bouts ⇒ σ_xx = −E·α·ΔT ; appuis simples ⇒ dilatation libre
u = α·ΔT·(x, y) sans contrainte. Code = test tests/thermoelastic_bar.rs :
use pyrucast::aggregate::Aggregate;
use pyrucast::atoms::{ElementType, Node};
use pyrucast::containers::field::Field;
use pyrucast::containers::finite_element_space::FiniteElementSpace;
use pyrucast::containers::mesh::{Mesh, SubMesh};
use pyrucast::containers::model::Model;
use pyrucast::containers::node_field::{NodeField, SubNodeField};
use pyrucast::coords::Coords;
use pyrucast::handle::Handle;
use pyrucast::models::tensor::Kinematics;
use pyrucast::ops::element_field::{deformation, interp_to_gauss, thermal_strain};
use pyrucast::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::node_field::internal_forces;
use pyrucast::ops::solver::lu::solve;
use pyrucast::Result;
#[test]
fn thermoelastic_constrained_bar_stress() -> Result<()> {
const E: f64 = 210_000.0;
const NU: f64 = 0.3;
const ALPHA: f64 = 1e-5;
const T_REF: f64 = 20.0;
const DT: f64 = 100.0;
const NX: usize = 4;
const NY: usize = 2;
const L: f64 = 4.0;
const H: f64 = 1.0;
let (hx, hy) = (L / NX as f64, H / NY as f64);
// ── QUA4 mesh on [0,L]×[0,H] ───────────────────────────────────────────
let coords = Handle::new(Coords::new(2)?);
let idx = |i: usize, j: usize| j * (NX + 1) + i;
let mut grid: Vec<Node> = Vec::new();
for j in 0..=NY {
for i in 0..=NX {
grid.push(Node::create_in(
coords.clone(),
&[i as f64 * hx, j as f64 * hy],
)?);
}
}
let mut mesh = Mesh::from_submesh(SubMesh::new(coords.clone(), ElementType::QUA4));
for j in 0..NY {
for i in 0..NX {
mesh.add_cell(&[
grid[idx(i, j)].id(),
grid[idx(i + 1, j)].id(),
grid[idx(i + 1, j + 1)].id(),
grid[idx(i, j + 1)].id(),
])?;
}
}
let fes = FiniteElementSpace::lagrange1(&mesh)?;
// ── Modèle : élasticité + deux bords en x encastrés + appui u_y en bas ──
let clamp = |target: &Model, nodes: &[Node], var: &str| -> Result<Model> {
let imposed = Mesh::from_submesh(SubMesh::poi1_from_nodes(nodes)?);
let multiplier = mesh::barycenter(&imposed)?;
model::dirichlet(target, var, &imposed, &multiplier, Default::default())
};
let left: Vec<Node> = (0..=NY).map(|j| grid[idx(0, j)].clone()).collect();
let right: Vec<Node> = (0..=NY).map(|j| grid[idx(NX, j)].clone()).collect();
let bottom: Vec<Node> = (0..=NX).map(|i| grid[idx(i, 0)].clone()).collect();
let mut model = model::elasticity(&fes, Kinematics::PlaneStress)?;
model = model.union(&clamp(&model, &left, "u_x")?)?;
model = model.union(&clamp(&model, &right, "u_x")?)?;
model = model.union(&clamp(&model, &bottom, "u_y")?)?;
// `alpha` supplied through the material field — an optional elastic component.
let materials = pyrucast::ops::element_field::material_field(
&model,
&[("E", E), ("nu", NU), ("alpha", ALPHA)],
)?;
// ── Imposed temperature T = T_ref + ΔT everywhere, carried at the Gauss points
let support = Handle::new(SubMesh::poi1_from_nodes(&grid)?);
let mut t_nodal = SubNodeField::from_poi1(&support, vec!["T".into()])?;
for n in &grid {
t_nodal.set_value(n.id(), "T", T_REF + DT)?;
}
let t_elem = interp_to_gauss(&NodeField::from_sub(t_nodal), &fes)?;
// ── Charge thermique f_th = ∫ Bᵀ D ε_th (BSIG de σ_th = D:ε_th) ─────────
let eps_th = thermal_strain(&t_elem, &materials, &fes, T_REF)?;
let sig_th =
pyrucast::ops::element_field::behavior::integrate(&model, &eps_th, None, &materials, None)?;
let f_th = internal_forces(&model, &sig_th, &NodeField::empty(), &materials)?;
// ── Assemblage + résolution ────────────────────────────────────────────
let solution = solve(
&pyrucast::ops::matrix::stiffness(&model, &materials)?,
&f_th,
)?;
// ── Déplacement propre (u_x, u_y) puis σ = D:(ε(u) − ε_th) ─────────────
let disp_support = Handle::new(SubMesh::poi1_from_nodes(&grid)?);
let mut disp = SubNodeField::from_poi1(&disp_support, vec!["u_x".into(), "u_y".into()])?;
for n in &grid {
disp.set_value(n.id(), "u_x", solution.value(n.id(), "u_x")?)?;
disp.set_value(n.id(), "u_y", solution.value(n.id(), "u_y")?)?;
}
let eps = deformation(&NodeField::from_sub(disp), &fes)?;
let eps_mech = eps.merge_field(&eps_th, |a, b| a - b)?;
let sigma = pyrucast::ops::element_field::behavior::integrate(
&model, &eps_mech, None, &materials, None,
)?;
// ── Vérification : σ_xx = −E·α·ΔT, σ_yy = 0 ────────────────────────────
let expected = -E * ALPHA * DT;
let tol = 1e-6 * expected.abs();
let sub = sigma.get(0)?.read();
for cell in 0..sub.cell_count() {
for g in 0..sub.gauss_count() {
assert!((sub.value(cell, g, "sigma_xx")? - expected).abs() < tol);
assert!(sub.value(cell, g, "sigma_yy")?.abs() < tol);
}
}
Ok(())
}
"""Thermomécanique non couplée — barre chauffée (contraintes planes).
Physique
--------
An imposed temperature ΔT generates a free thermal strain
`ε_th = α·(T − T_ref)`. En petites déformations la rigidité reste élastique ;
the thermal term acts only on the right-hand side (equivalent thermal load
`f_th = ∫ Bᵀ D ε_th`) and on the real stress
`σ = D:(ε(u) − ε_th)`. Uncoupled: the mechanics does not feed back on the thermics.
Bricks composed by hand (no "all-in-one" operator):
`interp_to_gauss` (T nodale → points de Gauss), `thermal_strain` (EPTH),
`integrate_behavior` (σ = D:ε), `internal_forces` (BSIG), `solve`, puis
`deformation` and a field subtraction for ε_mech = ε(u) − ε_th.
`alpha` travels through the material field (`material_field`) as an
**optional** component of the elasticity, beside `E`/`nu`.
Two regimes on the same bar
------------------------------
- **bloquée** (deux bords en x encastrés) : `σ_xx = −E·α·ΔT`, `σ_yy = 0` ;
- **libre** (appuis simples) : dilatation `u = α·ΔT·(x, y)`, `σ ≈ 0`.
Lancement ::
maturin develop --features extension-module
python examples/thermoelastique_barre.py
"""
import pyrucast
E, NU, ALPHA = 210_000.0, 0.3, 1e-5
T_REF, DT = 20.0, 100.0
NX, NY, L, H = 4, 2, 4.0, 1.0
def _clamp(target, nodes, var):
imposed = pyrucast.mesh.poi1_from_nodes(nodes)
multiplier = pyrucast.mesh.barycenter(imposed)
return pyrucast.model.dirichlet(target, var, imposed, multiplier)
def _bar():
"""An NX×NY grid of QUA4 on [0,L]×[0,H]. Returns (coords, grid, fes, idx)."""
c = pyrucast.Coords(2)
hx, hy = L / NX, H / NY
def idx(i, j):
return j * (NX + 1) + i
grid = [c.add_node([i * hx, j * hy]) for j in range(NY + 1) for i in range(NX + 1)]
mesh = pyrucast.Mesh(c, "QUA4")
for j in range(NY):
for i in range(NX):
mesh.unit().add_cell(
[
grid[idx(i, j)],
grid[idx(i + 1, j)],
grid[idx(i + 1, j + 1)],
grid[idx(i, j + 1)],
]
)
return c, grid, pyrucast.FiniteElementSpace(mesh), idx
def _uniform_temperature(c, grid, fes, value):
"""A temperature field 'T' = value everywhere, carried at the Gauss points."""
t_mesh = pyrucast.Mesh(c, "POI1")
for node in grid:
t_mesh.unit().add_cell([node])
t_nodal = pyrucast.NodeField(t_mesh, ["T"])
for node in grid:
t_nodal[0].set_value(node, "T", value)
return pyrucast.element_field.interp_to_gauss(t_nodal, fes)
def _displacement(solution, c, grid):
"""Extracts a clean (u_x, u_y) field (without the Lagrange multipliers)."""
u_mesh = pyrucast.Mesh(c, "POI1")
for node in grid:
u_mesh.unit().add_cell([node])
u = pyrucast.NodeField(u_mesh, ["u_x", "u_y"])
for node in grid:
u[0].set_value(node, "u_x", solution.value(node, "u_x"))
u[0].set_value(node, "u_y", solution.value(node, "u_y"))
return u
def _solve_thermal(model, materials, fes, c, grid):
"""ε_th → charge thermique → u → σ = D:(ε(u) − ε_th)."""
eps_th = pyrucast.element_field.thermal_strain(
_uniform_temperature(c, grid, fes, T_REF + DT), materials, fes, T_REF
)
sig_th = pyrucast.element_field.integrate_behavior(model, eps_th, materials)
# A **load**, not a residual: the divergence of the prescribed tensor, hence
# the geometric operator. They are then renamed into dual rows — that is
# where, and only where, these numbers become forces.
f_th = (
pyrucast.node_field.divergence(sig_th, "sigma")
.rename_component("div_sigma_x", "f_x")
.rename_component("div_sigma_y", "f_y")
)
solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), f_th)
u = _displacement(solution, c, grid)
sigma = pyrucast.element_field.integrate_behavior(
model, pyrucast.element_field.deformation(u, fes) - eps_th, materials
)
return u, sigma
def main() -> None:
# ── Régime bloqué : σ_xx = −E·α·ΔT ──────────────────────────────────────
c, grid, fes, idx = _bar()
left = [grid[idx(0, j)] for j in range(NY + 1)]
right = [grid[idx(NX, j)] for j in range(NY + 1)]
bottom = [grid[idx(i, 0)] for i in range(NX + 1)]
model = pyrucast.model.elasticity(fes, "plane_stress")
model = (
model
| _clamp(model, left, "u_x")
| _clamp(model, right, "u_x")
| _clamp(model, bottom, "u_y")
)
materials = pyrucast.element_field.material_field(
model, [("E", E), ("nu", NU), ("alpha", ALPHA)]
)
_u, sigma = _solve_thermal(model, materials, fes, c, grid)
expected = -E * ALPHA * DT
sub = sigma[0]
sxx = sub.value(0, 0, "sigma_xx")
print(f"Bloquée : σ_xx = {sxx:12.4f} (attendu {expected:.4f} = −E·α·ΔT)")
assert abs(sxx - expected) < 1e-6 * abs(expected)
# ── Régime libre : dilatation u = α·ΔT·(x, y), σ ≈ 0 ────────────────────
c, grid, fes, idx = _bar()
left = [grid[idx(0, j)] for j in range(NY + 1)]
bottom = [grid[idx(i, 0)] for i in range(NX + 1)]
model = pyrucast.model.elasticity(fes, "plane_stress")
model = model | _clamp(model, left, "u_x") | _clamp(model, bottom, "u_y")
materials = pyrucast.element_field.material_field(
model, [("E", E), ("nu", NU), ("alpha", ALPHA)]
)
u, sigma = _solve_thermal(model, materials, fes, c, grid)
tip = grid[idx(NX, NY)]
ux, uy = u.value(tip, "u_x"), u.value(tip, "u_y")
print(
f"Libre : u(coin) = ({ux:.6e}, {uy:.6e}) (attendu ({ALPHA * DT * L:.6e}, {ALPHA * DT * H:.6e}))"
)
assert abs(ux - ALPHA * DT * L) < 1e-9 and abs(uy - ALPHA * DT * H) < 1e-9
assert abs(sigma[0].value(0, 0, "sigma_xx")) < 1e-6
print("\nOK: blocked bar → σ_xx = −E·α·ΔT; free bar → expansion without stress.")
if __name__ == "__main__":
main()
Élasticité orthotrope et anisotrope
Introduction
L’élasticité linéaire suppose le matériau isotrope : deux constantes, aucune direction privilégiée. Beaucoup de matériaux n’obéissent pas à cette hypothèse — un composite tissé, un bois, une tôle laminée, un monocristal ont des raideurs différentes selon la direction.
pyrucast traite cela comme un axe à part entière, la symétrie matériau,
porté par le même modèle elasticity et orthogonal à l’hypothèse cinématique
(contraintes planes, déformations planes, axisymétrie, massif) :
| symétrie | constantes indépendantes | ce qu’elle décrit |
|---|---|---|
isotropic | 2 | pas de direction privilégiée |
orthotropic | 9 | trois plans de symétrie orthogonaux |
anisotropic | 21 | le cas général |
C’est la convention de Cast3M, où ISOTROPE / ORTHOTROPE / ANISOTROPE
qualifie le matériau d’une formulation plutôt que de nommer un modèle
différent. Les degrés de liberté ne changent donc pas : déplacement
u_x, u_y(, u_z) en primal, force nodale f_x, … en dual, exactement comme en
isotrope. Seule la matrice de Hooke D change.
Équations continues résolues
Les mêmes qu’en élasticité linéaire — équilibre ∇·σ + f = 0 en petites
déformations, avec ε = ½(∇u + ∇uᵀ). C’est la loi de comportement qui se
généralise :
\[ \sigma_{ij} = C_{ijkl}\,\varepsilon_{kl} \]
où \( C \) est le tenseur d’élasticité d’ordre 4. Ses symétries — mineures \( C_{ijkl} = C_{jikl} = C_{ijlk} \), qui viennent de celles de \( \sigma \) et \( \varepsilon \), et majeure \( C_{ijkl} = C_{klij} \), qui vient de l’existence d’un potentiel élastique \( W = \tfrac12\,\varepsilon : C : \varepsilon \) — le réduisent d’un tenseur à 81 composantes à une matrice \( 6\times6 \) symétrique en notation de Voigt, soit 21 constantes dans le cas général.
L’orthotropie est le cas où le matériau possède trois plans de symétrie
orthogonaux. Dans ses axes propres, la souplesse S = C⁻¹ se découple : les
termes normaux ne sont couplés qu’entre eux, et chaque cisaillement est isolé.
\[ 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} \]
Les relations de réciprocité \( \nu_{ji}/E_j = \nu_{ij}/E_i \) rendent la matrice symétrique, d’où neuf constantes seulement : trois modules d’Young, trois coefficients de Poisson, trois modules de cisaillement. Le bloc normal et les trois cisaillements sont découplés, ce qui est la définition même de l’orthotropie : une traction selon un axe propre ne produit aucun cisaillement.
Attention — un jeu de constantes n’est pas physique par construction :
Sdoit rester définie positive, ce qui imposeν_ij² < E_i/E_j. pyrucast le vérifie en inversantSet erronne si elle est singulière, plutôt que d’assembler une raideur non définie positive en silence.
Le repère d’orthotropie
Les constantes sont données dans les axes matériau, qui ne coïncident pas avec les axes globaux. Il faut donc dire où ils pointent.
pyrucast suit Cast3M et les décrit par des vecteurs, pas par des angles
d’Euler (MATE 'DIRECTION' V1 V2). Ils voyagent dans le champ matériau comme
n’importe quel autre coefficient :
| espace | composantes | signification |
|---|---|---|
| 2-D | V1X, V1Y | le premier axe matériau ; le deuxième est sa normale dans le plan |
| 3-D | V1X…V1Z, V2X…V2Z | les deux premiers axes ; le troisième est V1 × V2 |
Ils sont orthonormalisés en interne (Gram-Schmidt) : V2 n’a besoin d’être
que grossièrement perpendiculaire à V1, c’est le plan qu’ils engendrent qui
compte. Des vecteurs plutôt que des angles, parce qu’il n’y a aucune convention à
retenir, aucun cas de blocage de cardan — et surtout parce que le repère varie
alors naturellement d’une maille à l’autre (un composite bobiné, une pièce
courbe), en passant par le canal matériau existant.
Un V1 nul, ou un V2 parallèle à V1, est un repère dégénéré : il est
signalé, jamais complété arbitrairement.
Forme discrétisée
La chaîne est celle de l’élasticité — K_e = Σ_g Bᵀ D B |J| w, avec le même
opérateur B. Ce qui change tient en trois étapes, faites une fois par maille :
- construire
Ddans les axes matériau, où l’orthotropie est diagonale ; - le tourner vers les axes globaux ;
- le réduire au modèle cinématique (bloc
[xx, yy, xy]en déformations planes, sa condensation statique surε_zzen contraintes planes, le bloc[rr, zz, θθ, rz]en axisymétrie, le6×6complet en massif).
La rotation passe par le tenseur d’ordre 4, pas par une matrice de Bond
6×6 :
\[ 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], \]
R étant la rotation qui porte les axes matériau sur les axes globaux.
C’est un choix délibéré. En cisaillement ingénieur (γ = 2ε), le passage
Voigt ↔ tenseur ne porte aucun facteur — C_ijkl = D[voigt(i,j)][voigt(k,l)]
— et la rotation d’ordre 4 s’écrit sans la moindre convention à mémoriser. Le
coût, quelques centaines de multiplications par maille, est négligeable devant
l’assemblage, et il achète l’élimination de toute une famille d’erreurs
d’indices et de facteurs 2. L’isotropie, elle, court-circuite ce chemin et
garde ses formes fermées : les calculs isotropes existants conservent leurs
nombres exacts.
Variables et matériau
Primales u_x, u_y(, u_z), duales f_x, f_y(, f_z) — inchangées.
| symétrie | composantes matériau requises |
|---|---|
isotropic | E, nu |
orthotropic | E_1, E_2, E_3, nu_12, nu_13, nu_23, G_12, G_13, G_23 + le repère |
anisotropic | C_11 … C_66 (21, triangle supérieur) + le repère |
Les trois contrats sont disjoints. Ce n’est pas un détail : l’assembleur résout la zone matériau d’une physique par l’ensemble des composantes qu’elle exige, si bien qu’une zone isotrope et une zone orthotrope peuvent partager un maillage sans consolidation explicite.
Les constantes anisotropes sont nommées d’après le triangle supérieur de la
matrice de Voigt, dans l’ordre de ce dépôt [xx, yy, zz, yz, xz, xy] : C_11,
C_12, …, C_16, C_22, …, C_66. Ainsi C_44 est la raideur en yz, C_66
celle en xy.
Même en 2-D, les neuf constantes orthotropes sont exigées : la raideur hors-plan intervient en déformations planes et en axisymétrie, et le tenseur complet est de toute façon construit avant d’être réduit.
Le comportement (COMP) est linéaire, σ = D·ε, et rend les mêmes composantes
sigma_* que l’élasticité isotrope.
Mise en donnée (Rust, testé)
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::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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(())
}
Exemple Python
Le balayage du repère matériau, où l’on voit l’effet propre à l’orthotropie :
"""Orthotropic elasticity — a plate pulled off its material axes.
Problème
--------
A unit square in plane stress, pulled uniformly (traction ``S``) on its right
edge, with roller supports on the left and bottom edges. The material is
**orthotropic**: stiff in one direction, compliant in the other.
What the example shows is the effect of the **orthotropy frame**. It is given
by vectors, as in Cast3M (``MATE 'DIRECTION' V1 V2``): the components ``V1X``,
``V1Y`` travel in the material field just like the moduli. The first material
axis's angle is swept from 0° to 90°.
Two cases have an analytical solution, and they are the sweep's bounds:
* **0°** — the stiff axis is aligned with the traction ::
u_x(1, y) = S / E_1
* **90°** — it is the compliant axis that works ::
u_x(1, y) = S / E_2
In between, the plate **shears**: off its axes, an orthotropic material couples
traction and distortion (the ``D_16`` term of the rotated tensor is no longer
zero), and the right edge does not stay straight. That is precisely what
anisotropy brings, and what an isotropic computation cannot produce.
Lancement
---------
Once the extension is built in the venv ::
maturin develop --features extension-module
python examples/plaque_orthotrope.py
"""
import math
import pyrucast
# ── Problem data ────────────────────────────────────────────────────────────
E1 = 200.0 # modulus in the stiff direction (material axis 1)
E2 = 50.0 # module transverse
NU12 = 0.25
G12 = 30.0
S = 2.0 # traction on the right edge
N = 4 # grille N×N de QUA4
def maillage():
"""The unit square's QUA4 grid, its nodes and its FE space."""
h = 1.0 / N
c = pyrucast.Coords(2)
grid = [[c.add_node([i * h, j * h]) for i in range(N + 1)] for j in range(N + 1)]
mesh = pyrucast.Mesh(c, "QUA4")
for j in range(N):
for i in range(N):
mesh.unit().add_cell(
[grid[j][i], grid[j][i + 1], grid[j + 1][i + 1], grid[j + 1][i]]
)
return c, grid, pyrucast.FiniteElementSpace(mesh)
def rouleau(target, c, noeuds, variable):
"""A roller support ``variable = 0`` on the given nodes."""
imposed = pyrucast.Mesh(c, "POI1")
for n in noeuds:
imposed.unit().add_cell([n])
multiplier = pyrucast.mesh.barycenter(imposed)
return pyrucast.model.dirichlet(target, variable, imposed, multiplier)
def resoudre(angle_deg):
"""The displacement of corner (1, 0) for a material axis at ``angle_deg``."""
c, grid, fes = maillage()
# Orthotropic elasticity + both supports.
model = pyrucast.model.elasticity(fes, "plane_stress", symmetry="orthotropic")
model = model | rouleau(model, c, [grid[j][0] for j in range(N + 1)], "u_x")
model = model | rouleau(model, c, [grid[0][i] for i in range(N + 1)], "u_y")
# The material frame is material data like any other.
a = math.radians(angle_deg)
# Traction S on the right edge, as consistent nodal loads: a term of the
# model, whose density lives in the material.
bord = pyrucast.Mesh(c, "SEG2")
for j in range(N):
bord.unit().add_cell([grid[j][N], grid[j + 1][N]])
bord_fes = pyrucast.FiniteElementSpace(bord)
model = model | pyrucast.model.flux(bord_fes, model, "f_x")
materials = pyrucast.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", G12),
("G_13", G12),
("G_23", G12),
("V1X", math.cos(a)),
("V1Y", math.sin(a)),
("phi_f_x", S),
],
)
rhs = pyrucast.node_field.external_forces(model, materials)
solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), rhs)
coin = grid[0][N] # (1, 0)
haut = grid[N][N] # (1, 1)
return (
solution.value(coin, "u_x"),
solution.value(haut, "u_x") - solution.value(coin, "u_x"),
)
def main() -> None:
print("Orthotropic elasticity — sweeping the material frame")
print(f" E_1 = {E1}, E_2 = {E2}, nu_12 = {NU12}, G_12 = {G12}, traction S = {S}")
print()
print(" angle u_x(1,0) u_x gap on the right edge")
print(" " + "-" * 46)
for angle in (0.0, 22.5, 45.0, 67.5, 90.0):
ux, distorsion = resoudre(angle)
print(f" {angle:5.1f}° {ux:10.6f} {distorsion:+14.6f}")
# Both bounds are analytical: the stiff axis, then the compliant one.
ux0, _ = resoudre(0.0)
ux90, _ = resoudre(90.0)
print()
print(f" 0° : {ux0:.6f} (attendu S/E_1 = {S / E1:.6f})")
print(f" 90° : {ux90:.6f} (attendu S/E_2 = {S / E2:.6f})")
assert abs(ux0 - S / E1) < 1e-10
assert abs(ux90 - S / E2) < 1e-10
# Off axis, the traction induces shear — the right edge warps.
_, distorsion45 = resoudre(45.0)
assert abs(distorsion45) > 1e-4, "un orthotrope hors axes doit cisailler"
print()
print(f" At 45°, the right edge warps by {distorsion45:+.6f}:")
print(" this is the traction/shear coupling of off-axis orthotropy.")
if __name__ == "__main__":
main()
Il produit :
angle u_x(1,0) écart u_x sur le bord droit
----------------------------------------------
0.0° 0.010000 -0.000000
22.5° 0.017888 -0.004075
45.0° 0.031195 -0.008035
67.5° 0.039603 -0.005663
90.0° 0.040000 -0.000000
Les deux bornes sont analytiques — S/E₁ quand l’axe rigide est aligné sur la
traction, S/E₂ quand c’est l’axe souple. Entre les deux, le bord droit se
gauchit : hors de ses axes, un matériau orthotrope couple traction et
cisaillement (le terme D₁₆ du tenseur tourné n’est plus nul). C’est exactement
ce qu’un calcul isotrope ne peut pas produire, et le signe le plus visible que la
rotation fait son travail.
Compléments
Ce qui vaut comme vérification. Deux dégénérescences encadrent l’implémentation, et sont testées de bout en bout :
- une loi orthotrope nourrie de constantes isotropes doit se comporter comme l’isotrope, quel que soit son repère — c’est le contrôle le plus sévère de la rotation, puisque toute erreur d’indice brise l’invariance ;
- une loi anisotrope nourrie du tenseur isotrope doit faire de même, ce qui fixe l’ordre de lecture des 21 constantes : une permutation placerait les modules de cisaillement dans les mauvaises cases de Voigt.
Axisymétrie. L’orthotropie s’y combine sans rien de particulier. En 2-D le
troisième axe matériau est la direction hors-plan, c’est-à-dire l’orthoradiale
θ — ce qui est le comportement voulu pour un tube bobiné, dont la direction de
fibre est justement circonférentielle.
Ce que cela ne couvre pas. La symétrie matériau porte sur l’élasticité. Les lois non linéaires (plasticité, endommagement) restent bâties sur une élasticité isotrope ; l’endommagement orthotrope est le sujet d’un modèle propre, pas d’un axe de symétrie.
La même mécanique sert la conduction thermique orientée et la diffusion, avec un tenseur d’ordre 2 au lieu de 4.
Plasticité parfaite (von Mises)
Élastoplasticité parfaite (sans écrouissage) en petites déformations,
critère de von Mises (J2), écoulement associé. Mêmes éléments et mêmes
degrés de liberté que l’élasticité linéaire : 2-D (TRI3 /
QUA4) ou 3-D (TET4 / HEX8).
Équations continues résolues
- équilibre :
∇·σ + b = 0; - partition :
ε = εᵉ + εᵖ(déformation élastique + plastique) ; - élasticité :
σ = D : εᵉ; - critère :
f(σ) = q − σ_y ≤ 0, avecq = √(3/2 · s:s)la contrainte équivalente de von Mises (s= déviateur deσ) ; - écoulement associé :
ε̇ᵖ = λ̇ · ∂f/∂σ, conditions de Kuhn–Tuckerλ̇ ≥ 0, f ≤ 0, λ̇ f = 0.
Sans écrouissage, σ_y est constant : la contrainte équivalente est
plafonnée à σ_y (plateau parfaitement plastique).
Forme discrétisée
Conformément au découpage du cœur (voir Comportement — la boucle de Newton est pilotée en Python, le cœur Rust ne fournit que les briques), cette physique expose :
- rigidité (
build_stiffness_blocks) : la rigidité élastiqueK = ∫ Bᵀ D B dΩ, opérateur d’itération simple (Newton modifié) ; - tangente cohérente (
KTAN,assemble.tangent) :K_t = ∫ Bᵀ D_alg Bavec le module algorithmiqueD_algdu retour radial J2 (dérivée exacte, condensation contrainte-plane incluse) — émis par le comportement, relu par l’assembleur, pour un Newton complet à convergence quadratique ; - comportement (
COMP,integrate_behavior) : le retour radial exact, point de Gauss par point de Gauss, qui produit aussiD_alg.
À chaque itération, avec la même matrice \( B \) qu’en élasticité, on résout la correction \( \delta u \) du système linéarisé
\[
K_t\,\delta u = F_{\text{ext}} - F_{\text{int}}, \qquad
F_{\text{int}} = \int_\Omega B^\top \sigma\, d\Omega \;(\text{BSIG}),
\]
où la contrainte \( \sigma \) au point de Gauss sort du retour radial. La tangente cohérente \( K_t = \int_\Omega B^\top D_{\text{alg}} B\, d\Omega \) utilise le module algorithmique \( D_{\text{alg}} = \partial\sigma/\partial\varepsilon \) (dérivée exacte de l’application de retour), garantissant la convergence quadratique — au lieu de la rigidité élastique \( D \) du Newton modifié.
Retour radial (algorithme)
À partir de la déformation totale ε et de l’état plastique précédent
(εᵖ, p) :
- prédiction élastique :
σ_trial = D : (ε − εᵖ),q = √(3/2 s_trial:s_trial); - si
f = q − σ_y ≤ 0→ pas élastique, état inchangé ; - sinon (plasticité parfaite) :
Δp = f / (3μ), le déviateur est ramenés = s_trial · σ_y / q,Δεᵖ = Δp · (3/2) s_trial / q, puisεᵖ ← εᵖ + Δεᵖ,p ← p + Δp.
Le calcul interne est mené en 3-D quel que soit le modèle. La
déformation plane impose ε_zz = ε_yz = ε_xz = 0 ; la contrainte plane
résout la condition σ_zz = 0 par une méthode de la sécante locale autour du
retour radial.
Variables et matériau
- primal :
u_x, u_y(, u_z)— dual :f_x, f_y(, f_z). - matériau :
E(Young),nu(Poisson),sigma_y(limite d’élasticité). - état de début de pas A (entrée
prev, montage incrémental) : contrainteσ(A), tenseur de déformation plastiqueeps_p_xx … eps_p_xy(toujours 6 composantes 3-D), déformation plastique cumuléep, et déformationε(A). C’est la sortie du pas précédent ;Noneau premier pas (A = configuration de référence, tout à zéro). - sortie du
COMP(état de B, =prevdu pas suivant) : contrainte (sigma_*dans l’ordre de Voigt du modèle) suivie de l’état mis à jour et de l’écho deε(B)full-3-D (plussigma_zzpour les modèles plans en 2-D, dont le dual de Voigt l’omet) pour queprevsoit complet. Le prédicteur élastique estσ_trial = σ(A) + C:(ε(B) − ε(A)). - modèles :
plane_stress,plane_strain,axisymmetric(2-D) etfull_3d(3-D).
Axisymétrie
Le modèle "axisymmetric" s’applique sur une géométrie de révolution
(Coords.axisymmetric()) : Voigt à quatre
composantes [εrr, εzz, εθθ, γrz], nommées eps_xx, eps_yy, eps_zz, eps_xy
avec zz = orthoradial (convention Cast3M). Le modèle et le repère doivent
s’accorder dans les deux sens, comme en élasticité.
L’état interne étant déjà stocké en 3-D complet et le retour radial se
faisant en 3-D, la spécialisation axisymétrique se réduit à une table d’indices
[rr, zz, θθ, rz] → [xx, yy, zz, xy]. Deux conséquences :
- la déformation orthoradiale
ε_θθ = u_r/rest mesurée (produite pardeformation), pas supposée :ε(B)est donc entièrement connue, sans la résolution hors-plan qu’exige la contrainte plane ; σ_zzfait partie du dual de Voigt et n’est donc pas ré-émis en écho, contrairement aux modèles plans.
La tangente cohérente axisymétrique est la restriction [rr, zz, θθ, rz] de la
tangente 3-D, validée par différences finies dans tests/tangent.rs.
Mise en donnée (Rust) : poutre console, boucle de Newton complète
examples/plasticite_poutre_console.rs déroule un Newton complet (et non
un seul pas) autour des briques ci-dessus, côté API Rust : une poutre encastrée
cisaillée au bout, chargée par incréments jusqu’à développer une zone plastique
à l’encastrement.
L’algorithmie de Newton vit entièrement dans l’exemple, pas dans pyrucast :
la bibliothèque ne fournit que les opérateurs ponctuels — stiffness (rigidité
élastique, opérateur d’itération), deformation (ε), integrate (COMP,
retour radial → σ + état), internal_forces (BSIG, ∫ Bᵀσ) et solve
(LU creux, factorisation en cache). L’exemple assemble lui-même le résidu
r = F_ext − F_int, résout δu = K⁻¹ r et porte l’état interne VAR0 → VAR1
d’un pas au suivant. C’est un Newton modifié : K élastique constant,
assemblé et factorisé une seule fois.
Étant en Rust pur (aucune dépendance à Python), il sert aussi de banc de
parallélisme — les boucles chaudes (assemblage, deformation, integrate,
internal_forces) sont réévaluées à chaque itération :
RAYON_NUM_THREADS=1 PYRUCAST_NX=200 PYRUCAST_NY=40 \
cargo run --release --example plasticite_poutre_console
Variables d’environnement : PYRUCAST_NX / PYRUCAST_NY (mailles),
PYRUCAST_NSTEPS (pas de charge), PYRUCAST_PMAX (charge finale).
Exemple Python
La boucle de Newton (assemblage du résidu, résolution, mise à jour de l’état) s’écrit en Python ; voici l’usage d’un pas de la brique d’intégration :
import pyrucast
model = pyrucast.model.plasticity_perfect(fes, "plane_stress")
materials = pyrucast.element_field.material_field(
model, [("E", 210_000.0), ("nu", 0.3), ("sigma_y", 250.0)]
)
# Strain ε(B) from the current displacement field (a geometric op).
strain = pyrucast.element_field.deformation(u, fes)
# Integration A→B: `prev` = the previous step's output (None at the first step).
state = pyrucast.element_field.integrate_behavior(
model, strain, materials, prev=prev_state
)
sigma_xx = state[0].value(0, 0, "sigma_xx")
p = state[0].value(0, 0, "p") # déformation plastique cumulée
Pour réinjecter l’état au pas suivant, il suffit de passer state comme
prev au prochain appel — la sortie porte déjà l’état complet de B (σ, VAR1,
ε(B)). Aucune fusion de champs n’est nécessaire. La boucle de Newton
complète (pas de charge, résidu, résolution, portage de l’état) est écrite
dans examples/plasticite_poutre_console.py — même architecture que la mise en
donnée Rust ci-dessus.
Lois d’écoulement plastique
Introduction
La plasticité parfaite est un cas particulier. Toutes les lois élastoplastiques de pyrucast partagent la même physique — mêmes degrés de liberté, même rigidité élastique comme opérateur d’itération, même montage incrémental A → B, même état interne — et ne diffèrent que par leur surface de charge et leur règle d’écoulement.
La loi est donc un attribut du modèle de plasticité, pas un modèle à part.
C’est la convention de Cast3M, où PLASTIQUE PARFAIT, PLASTIQUE ISOTROPE,
PLASTIQUE DRUCKER_PRAGER et PLASTIQUE OTTOSEN sont des variantes d’une seule
formulation.
| loi | surface | ce qu’elle capture | matériau |
|---|---|---|---|
plasticity_perfect | q = σ_y | métal sans écrouissage | E, nu, sigma_y |
plasticity_isotropic | q = σ_y + H·p | métal écrouissable | + H |
drucker_prager | α·I₁ + β·q = k | sols, roches, poudres — sensibilité à la pression, écrouissage borné | E, nu, friction, k, psi (+ six facultatifs) |
ottosen | 4 paramètres, dépendante de l’angle de Lode | béton — traction ≠ compression | E, nu, a, b, k_1, k_2, sigma_c |
Ce qui est mutualisé dans src/models/plastic/ : le prédicteur élastique, la
boucle sécante des contraintes planes (qui résout σ_zz(B) = 0 autour de
n’importe quelle loi, si bien qu’aucune loi ne connaît les contraintes planes),
le retour par plan sécant pour les surfaces sans forme fermée, et la tangente
cohérente.
L’état est toujours porté en 3-D complet (six eps_p_* et un p cumulé)
quel que soit le modèle 2-D : chaque retour est ainsi identique en contraintes
planes, déformations planes, axisymétrie et massif — seules les projections
d’entrée et de sortie changent.
Équations continues résolues
Les quatre lois partagent le cadre de l’élastoplasticité en petites
déformations, et n’en particularisent que deux fonctions — la surface f et
le potentiel g :
- partition : \( \varepsilon = \varepsilon^e + \varepsilon^p \) ;
- élasticité : \( \sigma = D : \varepsilon^e = D : (\varepsilon - \varepsilon^p) \) ;
- domaine élastique : \( f(\sigma, \mathcal V) \le 0 \), où \( \mathcal V \) rassemble les variables d’écrouissage ;
- règle d’écoulement : \( \dot\varepsilon^p = \dot\lambda\,\dfrac{\partial g}{\partial\sigma} \), l’écoulement étant dit associé si \( g = f \) ;
- écrouissage : \( \dot{\mathcal V} = \dot\lambda\,h(\sigma, \mathcal V) \) ;
- Kuhn-Tucker : \( \dot\lambda \ge 0 \), \( f \le 0 \), \( \dot\lambda\,f = 0 \), et la consistance \( \dot\lambda\,\dot f = 0 \) tant que l’on plastifie.
Toutes les surfaces s’écrivent sur les invariants de la contrainte :
\[ I_1 = \operatorname{tr}\sigma = 3\sigma_m, \qquad s = \sigma - \sigma_m\,I, \qquad J_2 = \tfrac12\,s\!:\!s, \qquad J_3 = \det s, \]
d’où la contrainte équivalente \( q = \sqrt{3J_2} \) et l’angle de Lode \( \theta \), qui repère la direction dans le plan déviatorique :
\[ \cos 3\theta = \frac{3\sqrt3}{2}\,\frac{J_3}{J_2^{3/2}}, \qquad \theta \in [0, \pi/3]. \]
C’est le jeu d’invariants retenu qui classe les quatre lois : von Mises ne voit que \( q \) ; Drucker-Prager ajoute \( I_1 \), donc la pression ; Ottosen ajoute en plus \( \theta \), donc la direction déviatorique.
Forme discrétisée — le problème incrémental
Sur un pas A → B, les conditions de Kuhn-Tucker deviennent un problème de projection : trouver \( \Delta\lambda \ge 0 \) tel que
\[ \sigma_B = D : \Big(\varepsilon_B - \varepsilon^p_A - \Delta\lambda\, \frac{\partial g}{\partial\sigma}\Big), \qquad f(\sigma_B, \mathcal V_B) = 0 . \]
Le prédicteur élastique gèle la plasticité sur le pas :
\[ \sigma^{\text{tr}} = D : (\varepsilon_B - \varepsilon^p_A). \]
Si \( f(\sigma^{\text{tr}}, \mathcal V_A) \le 0 \), le pas est élastique et l’état interne ne bouge pas. Sinon il faut retourner sur la surface — et c’est là, et seulement là, que les lois diffèrent.
Écrouissage isotrope
La surface se dilate avec la déformation plastique cumulée :
\[ f(\sigma, p) = q - \big(\sigma_y + H\,p\big), \qquad \frac{\partial f}{\partial\sigma} = \frac{3}{2}\,\frac{s}{q}, \qquad \dot p = \dot\lambda . \]
La normale est colinéaire au déviateur et de trace nulle : le retour est radial et la plasticité isochore. Le déviateur d’essai n’étant que remis à l’échelle, \( q_B = q^{\text{tr}} - 3\mu\,\Delta p \), la consistance devient une équation affine dont la solution est fermée :
\[ q^{\text{tr}} - 3\mu\,\Delta p = \sigma_y + H\,(p_A + \Delta p) \quad\Longrightarrow\quad \Delta p = \frac{q^{\text{tr}} - \sigma_y(p_A)}{3\mu + H}, \]
la mise à jour étant alors
\[ s_B = s^{\text{tr}}\Big(1 - \frac{3\mu\,\Delta p}{q^{\text{tr}}}\Big), \qquad \varepsilon^p_B = \varepsilon^p_A + \frac{3\,\Delta p}{2\,q^{\text{tr}}}\,s^{\text{tr}}, \qquad \sigma_m \ \text{inchangé.} \]
H = 0 redonne exactement la loi parfaite — un seul chemin de code sert les
deux, ce que vérifie un test.
Sa tangente cohérente est la seule analytique du lot, le module algorithmique classique évalué au prédicteur :
\[ D_{\text{alg}} = K\,I \otimes I + 2\mu\,\theta\,\mathbb I_{\text{dev}}
- 2\mu\,\bar\theta\;\hat n \otimes \hat n, \] \[ \theta = \frac{\sigma_y(p_A + \Delta p)}{q^{\text{tr}}}, \qquad \bar\theta = \frac{3\mu}{3\mu + H} - (1 - \theta), \qquad \hat n = \frac{s^{\text{tr}}}{\lVert s^{\text{tr}}\rVert}. \]
Le facteur \( \theta \) est ce qui distingue le module algorithmique du module élastoplastique continu : il rend compte du pas fini, et l’omettre coûterait à Newton sa convergence quadratique. À \( H = 0 \) les deux coefficients coïncident (\( \bar\theta = \theta \)) et l’on retrouve la tangente parfaite : l’écrouissage coûte un terme, pas une seconde dérivation.
Drucker-Prager
Sols, roches, bétons et poudres sont plus résistants en compression qu’en traction : leur seuil dépend de la pression hydrostatique, que von Mises ignore. Drucker-Prager est le cône le plus simple qui le capture :
\[ f(\sigma) = q + \alpha\,I_1 - k \]
Un écoulement non associé
Un écoulement associé sur ce cône ferait dilater le matériau sous cisaillement
d’exactement ce que son frottement implique — bien trop pour un milieu granulaire
réel. Le potentiel plastique porte donc sa propre pente, la dilatance ψ :
\[ g(\sigma) = q + \psi\,I_1, \qquad \psi \le \alpha \]
ψ = α redonne l’écoulement associé ; ψ = 0 donne un écoulement plastique
isochore à résistance frottante.
Le retour sur le flanc reste fermé
La normale au potentiel se sépare en une part déviatorique et une part sphérique :
\[ \frac{\partial g}{\partial\sigma} = \frac{3}{2}\,\frac{s}{q} + \psi\,I . \]
L’opérateur élastique envoie la première sur \( 3\mu \) dans q et la seconde
sur \( 9K\psi \) dans I₁, si bien que les deux invariants sont affines en
le multiplicateur :
\[ q_B = q^{\text{tr}} - 3\mu\,\Delta\lambda, \qquad I_{1,B} = I_1^{\text{tr}} - 9K\psi\,\Delta\lambda, \]
et la consistance \( f(\sigma_B) = 0 \) se résout, comme en J2, sans itérer :
\[ \Delta\lambda = \frac{q^{\text{tr}} + \alpha I_1^{\text{tr}} - k}{3\mu + 9K\alpha\psi}, \qquad \Delta\varepsilon^p = \frac{3\,\Delta\lambda}{2\,q^{\text{tr}}}\,s^{\text{tr}}
- \psi\,\Delta\lambda\,I . \]
Le terme \( 9K\alpha\psi \) au dénominateur est le couplage pression-cisaillement : c’est là que la dilatance rigidifie (ou non) la réponse, et il disparaît dès que \( \psi = 0 \).
Écrouissage et surface ultime
La cohésion croît avec la déformation plastique cumulée, \( dk = H\,dp \), le module \( H \) étant algébrique — un \( H \) négatif adoucit. Laissée seule cette croissance serait sans borne, aussi une seconde surface, dite ultime, l’arrête :
[ \text{initiale } \alpha I_1 + \beta q = k, \qquad \text{ultime } \alpha_u I_1 + \beta_u q = k_u . ]
Les deux sont lues comme les extrémités d’une seule interpolation, que l’écrouissage lui-même pilote :
[ \lambda = \mathrm{clamp}!\left(\frac{H,p}{k_u - k},, 0,, 1\right), \qquad \big(\alpha, \beta, k\big)(p) = (1-\lambda),\big(\alpha, \beta, k\big) + \lambda,\big(\alpha_u, \beta_u, k_u\big). ]
C’est une interprétation, et il vaut mieux le dire. Cast3M donne les deux surfaces et \( dK = H,dp \) sans préciser comment elles se rencontrent. Les lire comme une surface qui interpole a deux mérites : \( k(p) = k + H p \) exactement tant que la limite n’est pas atteinte, donc la loi d’écrouissage annoncée est reproduite telle quelle ; et la surface de charge reste unique, si bien que le retour garde un cône et un sommet au lieu de faire naître un coin entre deux.
La contrepartie est que la condition de cohérence cesse d’être linéaire — la surface bouge avec le multiplicateur qu’on cherche. Elle est alors résolue par un Newton encadré, la solution linéaire servant à la fois de premier itéré et de borne. C’est le seul cas où cette loi itère : sans écrouissage, le retour reste la forme fermée ci-dessus.
Les neuf paramètres, et ce qu’ils redonnent
| Cast3M | ici | défaut | rôle |
|---|---|---|---|
ALFA | friction | requis | pente du cône |
K | k | requis | cohésion |
GAMM | psi | requis | dilatance du potentiel |
BETA | beta | 1 | poids déviatorique du critère |
DELT | delta | 1 | poids déviatorique du potentiel |
H | H | 0 | module d’écrouissage |
ETA | friction_ult | friction | pente de la surface ultime |
MU | beta_ult | beta | poids déviatorique ultime |
KL | k_ult | k | cohésion ultime |
Six sur neuf sont facultatifs, et leurs défauts font du cône simple le cas sans configuration : \( k_u = k \) ne laisse aucune place à l’écrouissage, l’interpolation reste figée sur la surface initiale, et il ne reste que le cône parfaitement plastique — trois nombres, comme avant.
Les deux modèles de Cast3M sont alors des préréglages de celui-ci :
PLASTIQUE DRUCKER_PARFAIT—psi = friction(etdelta = beta), ce qui rend l’écoulement associé, tous les autres à leur défaut. Cast3M le paramètre par les limites en traction et compression simples, d’où l’on tire \( \text{friction} = \frac{|LCS| - LTR}{|LCS| + LTR} \) et \( k = \frac{2,|LCS|,LTR}{|LCS| + LTR} \).PLASTIQUE DRUCKER_PRAGER— les neuf.
Le sommet
Un cône a une pointe, en \( I_1 = k/\alpha \), \( s = 0 \). Le critère qui la détecte est celui-là même que donne la formule fermée : si elle rend \( q_B < 0 \), le retour lisse a dépassé l’axe hydrostatique et la solution n’est pas admissible. La contrainte s’effondre alors sur la pointe,
\[ s_B = 0, \qquad \sigma_{m,B} = \frac{k}{3\alpha}, \]
tout ce que le prédicteur avait construit au-delà devenant plastique :
\[ \Delta\varepsilon^p = \frac{s^{\text{tr}}}{2\mu}
- \frac{I_1^{\text{tr}} - k/\alpha}{9K}\,I, \qquad \Delta p = \frac{q^{\text{tr}}}{3\mu}. \]
C’est le cas qu’une implémentation naïve rate silencieusement sous forte traction. Avec \( \alpha = 0 \) le cône est un cylindre, il n’a pas de sommet, et cette branche est inatteignable — le retour sur le flanc réussit toujours, puisque c’est von Mises.
Au sommet la contrainte est figée, donc la tangente y est nulle — et délibérément. Renvoyer le module élastique, la solution de facilité, rendrait la tangente incohérente avec le retour. Un corps entièrement à son sommet assemble donc une tangente singulière : ce n’est pas un artefact, c’est le constat honnête qu’un tel matériau ne porte plus rien.
Ottosen
Le béton casse très différemment en traction et en compression, et sa résistance sous une pression donnée dépend de la direction déviatorique de la contrainte. Ni von Mises (aveugle à la pression) ni Drucker-Prager (aveugle à cette direction) ne le capturent. La surface d’Ottosen le fait par une dépendance à l’angle de Lode :
\[ f(\sigma) = a\frac{J_2}{\sigma_c^2} + \lambda(\theta)\frac{\sqrt{J_2}}{\sigma_c} + b\frac{I_1}{\sigma_c} - 1 \]
où la fonction de forme déviatorique vaut, selon le signe de \( \cos 3\theta \),
\[ \lambda(\theta) = \begin{cases} k_1\,\cos\!\Big[\tfrac13\arccos\big(k_2\cos 3\theta\big)\Big] & \text{si } \cos 3\theta \ge 0,\\ k_1\,\cos\!\Big[\tfrac{\pi}{3} - \tfrac13\arccos\big(-k_2\cos 3\theta\big)\Big] & \text{si } \cos 3\theta < 0 . \end{cases} \]
La première branche couvre le méridien de traction
(\( \theta = 0 \)), la seconde celui de compression
(\( \theta = \pi/3 \)) ; k₁ fixe l’ouverture de la section et k₂ ∈ [0,1]
son écart à un cercle. Le terme en \( J_2 \) rend les méridiens courbes et
le terme en \( I_1 \) les incline, si bien que la section déviatorique est un
triangle arrondi qui s’ouvre vers la compression — ce qui est tout l’objet.
Intégrée par plan sécant, avec une normale numérique
Il n’existe pas de retour fermé exploitable sur cette surface. Pire, la normale
∂f/∂σ demande de dériver λ(θ) à travers arccos et J₃ — une expression
assez longue pour qu’une erreur de signe y soit invisible en relecture et ne se
manifeste que par une direction d’écoulement légèrement fausse.
Le retour passe donc par l’algorithme du plan sécant, qui n’a besoin que du
scalaire f(σ). Partant du prédicteur, on linéarise le critère à l’itéré
courant, on en déduit un multiplicateur, on corrige, et l’on recommence :
\[ n^{(i)} = \frac{\partial f}{\partial\sigma}\Big|_{\sigma^{(i)}}, \qquad \Delta\lambda^{(i)} = \frac{f(\sigma^{(i)})}{n^{(i)} : D : n^{(i)}}, \] \[ \sigma^{(i+1)} = \sigma^{(i)} - \Delta\lambda^{(i)}\,D : n^{(i)}, \qquad \varepsilon^{p\,(i+1)} = \varepsilon^{p\,(i)} + \Delta\lambda^{(i)}\,n^{(i)}, \qquad p^{(i+1)} = p^{(i)} + \Delta\lambda^{(i)}, \]
jusqu’à \( |f| \le \varepsilon_{\text{tol}} \) — f étant normalisé par
\( \sigma_c \), la tolérance l’est aussi. Le schéma est semi-implicite : la
normale est ré-évaluée à l’itéré courant plutôt que résolue implicitement, ce
qui converge robustement sur une surface fortement courbe sans demander de
dérivées secondes — c’est ce qui en fait le bon choix ici.
La normale elle-même vient de différences centrées sur f :
\[ n_i \simeq \frac{f(\sigma + h\,e_i) - f(\sigma - h\,e_i)}{2h}, \qquad h = 10^{-6}\,\sigma_c . \]
Le critère est ainsi exact et le gradient précis à \( O(h^2) \). Échanger un gradient analytique invérifiable contre un gradient numérique qui ne peut pas être mal dérivé est le bon compromis ici.
La tangente cohérente, et deux limites assumées
Seule von Mises garde une tangente analytique, parce que seule sa forme fermée a été confrontée à une différence finie. Toutes les autres l’obtiennent par perturbation — des 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 appels au retour par point de Gauss. Le pas doit rester bien
au-dessus du bruit du retour — celui d’Ottosen ou de Gurson converge à une
tolérance, pas exactement — et bien en dessous de l’échelle de courbure de la
surface ; 1e-6·‖ε‖ tient confortablement entre les deux. Les colonnes de
cisaillement sont divisées par deux en sortie, ce qui transforme ∂σ/∂ε_ij en
∂σ/∂γ_ij, la convention ingénieur du reste du dépôt.
La dérivation analytique de Drucker-Prager, écrite d’abord, était fausse de 24 % — plausible, et fausse. Seul l’oracle par différences finies de
tests/plastic_laws.rsl’a dit. La tangente par perturbation qui l’a remplacée ne peut pas être mal dérivée, coûte douze évaluations d’une mise à jour fermée, et laisse la convergence de Newton quadratique.
La tangente est symétrisée. D_alg est réduit au triangle supérieur puis relu
en miroir, un format qui ne peut pas porter la tangente réellement non
symétrique d’une loi non associée. Celle de Drucker-Prager est donc symétrisée — le compromis d’ingénierie
usuel, qui coûte à Newton son taux quadratique sur cette loi et rien d’autre, et
garde symétriques tous les consommateurs en aval.
Une tangente doublement numérique n’est précise que jusqu’à un point. Ottosen
dérive f pour obtenir sa normale, puis la tangente dérive toute cette carte
itérative ; les deux échelles d’erreur se composent, pour environ 10 % d’écart à
la dérivée exacte. Newton converge quand même — il lui faut une tangente assez
bonne pour converger, pas une tangente exacte à la précision machine — et le test
annonce le chiffre plutôt que de le cacher derrière une tolérance lâche partout.
Mise en donnée (Rust, testé)
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::integrate, deformation, material_field};
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(())
}
Exemple Python
model = pyrucast.model.drucker_prager(fes, "full_3d")
materials = pyrucast.element_field.material_field(
model,
[("E", 20_000.0), ("nu", 0.2), ("friction", 0.3), ("k", 30.0), ("psi", 0.1)],
)
strain = pyrucast.element_field.deformation(u, fes)
k_t = pyrucast.matrix.tangent(model, materials, strain)
La boucle de Newton reste orchestrée en Python, comme pour toute non-linéarité :
le noyau Rust fournit la mise à jour ponctuelle et D_alg, pas la boucle.
Compléments
Ce que vaut chaque test. Un test de plasticité qui n’affirme que « la
contrainte a baissé » ne prouve rien. Chaque loi est épinglée par sa propriété
définissante : la consistance q = σ_y + H·p pour l’écrouissage, l’atterrissage
sur le cône et l’effondrement au sommet pour Drucker-Prager, l’atterrissage sur
la surface à quatre paramètres et l’écart traction/compression pour Ottosen. Et
toutes ont leur tangente confrontée à une différence centrée des forces
internes.
Renommages. Model.plasticity est d’abord devenue plasticity_perfect,
pour que les quatre lois se nomment de la même façon ; puis le catalogue entier
a quitté le type Model pour le module des opérateurs (2026-08-25). On écrit
aujourd’hui pyrucast.model.plasticity_perfect(fes, model). Seuls les noms ont
changé.
Fluage et viscoplasticité
Introduction
Une loi indépendante du temps plastifie instantanément dès que la contrainte atteint sa surface. Une loi visqueuse, non : la contrainte peut rester hors de la surface, et l’écoulement qui l’y ramène prend du temps. C’est la sur-contrainte qui pilote la vitesse,
\[ \dot{p} = g(\sigma, p, \ldots) \]
et le pas est intégré implicitement, si bien que le résultat dépend de dt.
C’est pourquoi ces lois erronnent en l’absence d’incrément de temps. Intégrer
une loi de fluage comme si elle était instantanée produirait un nombre plausible
et faux ; refuser est la seule réponse honnête. L’argument existe depuis toujours
dans la signature du comportement (dt: Option<f64>) — ce sont les premières
lois à s’en servir.
Comme les lois plastiques, ce sont des attributs du même modèle : mêmes degrés de liberté, même montage incrémental, même état interne étendu au besoin.
| loi | vitesse | ce qu’elle décrit | matériau |
|---|---|---|---|
creep_norton | ṗ = (q/K)^n | fluage secondaire (stationnaire) | E, nu, K, n |
creep_lemaitre | ṗ = (q/K)^N · p^(−M) | fluage primaire, par écrouissage en déformation | E, nu, K, N, M |
creep_blackburn | primaire saturant + secondaire | les deux stades, dépendance en sinh | E, nu, A_1, alpha_1, r_1, B_s, beta_s |
viscoplasticity_chaboche | ṗ = ⟨(J(σ−X) − R − k)/K⟩^n | viscoplasticité cyclique | + k, K, n, C_1, gamma_1, b, Q |
viscoplasticity_lemaitre_chaboche | la même, sur σ/(1−D) | + endommagement ductile | + S, s, D_c |
Équations continues résolues
Le cadre est celui de la plasticité, moins les conditions de Kuhn-Tucker : la partition \( \varepsilon = \varepsilon^e + \varepsilon^{vp} \) et l’élasticité \( \sigma = D : (\varepsilon - \varepsilon^{vp}) \) sont les mêmes, mais le multiplicateur n’est plus une inconnue de consistance — il est donné par une loi de vitesse :
\[ \dot\varepsilon^{vp} = \dot p\,\frac{3}{2}\,\frac{s}{q}, \qquad \dot p = g(q, p, \mathcal V), \qquad q = \sqrt{3J_2}. \]
L’écoulement reste déviatorique — donc isochore — et radial, la direction étant celle de von Mises. Il n’y a plus de surface à atteindre : la contrainte peut rester hors du domaine, et c’est l’écart qui pilote la vitesse à laquelle elle y revient. Sans le facteur \( dt \) que porte cette équation, la loi n’a tout simplement pas de sens.
Un seul solveur pour toutes
Le pas est intégré par la θ-méthode avec θ = 1 (Euler implicite) : la vitesse est évaluée à la fin du pas. La direction d’écoulement étant fixée par le prédicteur, le déviateur ne fait que se contracter,
\[ q(\Delta p) = q^{\text{tr}} - 3\mu\,\Delta p, \]
et tout le pas se ramène à une équation scalaire en le multiplicateur :
\[ R(\Delta p) = \Delta p - \Delta t\;g\big(q(\Delta p),\; p_A + \Delta p,\;\ldots\big) = 0 . \]
R est croissante (la vitesse décroît quand la contrainte se relaxe), donc la
racine est unique dans \( [0,\; q^{\text{tr}}/3\mu] \) — la borne supérieure
étant le multiplicateur qui relaxerait tout le déviateur.
Le solveur fait un Newton dessus, encadré et doublé d’une dichotomie. Ce
n’est pas de la prudence gratuite : les fonctions de vitesse sont raides (q^n
avec n jusqu’à 20 varie de plusieurs décades à l’intérieur d’un pas), et un
Newton nu y diverge aussi volontiers qu’il converge — soit vers un multiplicateur
négatif, soit vers l’infini. Une dichotomie ne peut faire ni l’un ni l’autre.
Les trois lois de fluage
Norton (ou Norton-Odqvist) est le cheval de bataille du fluage stationnaire :
\[ \dot p = \left(\frac{q}{K}\right)^{n}. \]
Il n’y a aucun seuil : toute contrainte flue, même lentement, et c’est ce qui
distingue le fluage de la plasticité. K est la contrainte de référence, n la
sensibilité — couramment 3 à 10 pour un métal, ce qui fait varier la vitesse de
plusieurs décades à l’intérieur d’un pas.
Lemaitre ajoute un stade primaire par écrouissage en déformation :
\[ \dot p = \left(\frac{q}{K}\right)^{N} p^{-M}, \qquad M > 0 . \]
La déformation accumulée ralentit elle-même l’écoulement : à contrainte
constante, l’intégration donne \( p(t) \propto t^{1/(1+M)} \), la courbe
concave caractéristique du fluage primaire. Aucune dépendance explicite au temps
n’apparaît, et c’est précisément ce qui rend la loi utilisable sous charge
variable — une forme à écrouissage temporel y serait fausse. (p est
plancherné à une valeur minuscule pour que \( p^{-M} \) reste fini au premier
pas.)
Blackburn décrit les deux stades, avec le primaire suivi comme sa propre variable :
\[ \dot p_{\text{prim}} = r\,\big(\varepsilon_\infty(q) - p_{\text{prim}}\big), \qquad \varepsilon_\infty(q) = A\,\sinh(\alpha q), \] \[ \dot p = \dot p_{\text{prim}} + B\,\sinh(\beta q). \]
Le primaire approche exponentiellement son asymptote \( \varepsilon_\infty \)
puis s’éteint (\( r \) est la vitesse de saturation) ; le secondaire persiste
indéfiniment. La dépendance en sinh est ce qui permet à un seul jeu de
paramètres de couvrir plusieurs décades de contrainte, là où une loi puissance
échoue : \( \sinh \) est linéaire à faible contrainte et exponentiel à forte.
Implicitement, l’équation du primaire s’inverse en un pas,
\[ p_{\text{prim}}^{B} = \frac{p_{\text{prim}}^{A} + \Delta t\,r\,A\,\sinh(\alpha q)}{1 + \Delta t\,r}. \]
La déformation primaire est suivie comme sa propre variable interne (
p_prim), et non déduite du total. Ce n’est qu’à cette condition que la loi s’intègre correctement sous charge variable — toute la raison de préférer une forme en déformation à une forme en temps.
Chaboche, et sa variante endommageable
\[ f = J(\sigma - X) - R - k, \qquad \dot{p} = \left\langle \frac{f}{K} \right\rangle^n \] \[ \dot{X} = \tfrac{2}{3}C\,\dot{\varepsilon}_{vp} - \gamma X \dot{p}, \qquad \dot{R} = b(Q - R)\dot{p} \]
La contrainte de rappel X est ce qui rend la loi utilisable en cyclique :
elle translate la surface de charge, si bien que la replastification en sens
inverse survient tôt — l’effet Bauschinger, qu’aucune loi isotrope ne peut
produire. γ est ce qui fait saturer la translation au lieu de la laisser
croître sans borne (X → C/γ).
Les trois premières lois n’en portent pas, et c’est délibéré : un fluage décrit un régime monotone, où l’effet que modélise une contrainte de rappel ne se manifeste pas. Les deux dernières en portent, et cela leur coûte sept variables internes de plus.
Les crochets \( \langle\cdot\rangle \) sont la partie positive : sous la
surface, la vitesse est nulle et le comportement redevient élastique. C’est la
seule des cinq lois à porter un seuil k — un fluage n’en a pas.
La variante endommageable (Lemaitre-Chaboche) remplace partout la contrainte par la contrainte effective, au sens de la contrainte portée par la section restante :
\[ \tilde\sigma = \frac{\sigma}{1 - D}, \qquad \dot D = \left(\frac{Y}{S}\right)^{s}\dot p, \qquad Y = \frac{\tilde\sigma_{\text{eq}}^{2}}{2E}, \]
où \( Y \) est le taux de restitution d’énergie élastique, la force
thermodynamique conjuguée de D — sa forme complète porte un facteur de
triaxialité, pris ici à sa valeur déviatorique, simplification usuelle pour un
trajet proportionnel. Un matériau endommagé flue plus vite, ce qui l’endommage
davantage : c’est ce couplage qui produit le fluage tertiaire et, à D_c, la
rupture. D ne décroît jamais et est écrêté à D_c.
L’intégration
La direction d’écoulement est gelée au prédicteur, ce qui rend le pas radial
dans l’espace décalé et le ramène à la même équation scalaire que les fluages.
Les deux variables d’écrouissage sont alors implicites en Δp :
\[ X_B = \frac{X_A + \tfrac23 C\,\Delta p\,\hat n}{1 + \gamma\,\Delta p}, \qquad R_B = \frac{R_A + b\,Q\,\Delta p}{1 + b\,\Delta p}, \]
où \( \hat n = \tfrac32 (s - X)/J \) est la direction gelée. Les deux sont l’inversion exacte des lois d’évolution discrétisées en Euler implicite, et l’on y voit apparaître directement les asymptotes : \( J(X) \to C/\gamma \) et \( R \to Q \) quand \( \Delta p \to \infty \).
Un traitement pleinement implicite ré-évaluerait la direction, au prix d’un Newton tensoriel ; le geler est le schéma semi-implicite usuel, d’erreur du second ordre en le pas.
J(σ̃ − X)en fin de pas est calculé sur les tenseurs, non réduit à une formule scalaire. La réduction est faisable mais délicate — la contrainte de rappel en début de pas n’est pas parallèle à la direction d’écoulement — et une erreur y serait invisible. Construire le tenseur ne peut pas l’être.
Mise en donnée (Rust, testé)
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::integrate, deformation, material_field};
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), None, 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(())
}
Exemple Python
model = pyrucast.model.creep_norton(fes, "full_3d")
materials = pyrucast.element_field.material_field(
model, [("E", 150_000.0), ("nu", 0.3), ("K", 400.0), ("n", 5.0)]
)
# The time step is mandatory: without it the law refuses to integrate.
strain = pyrucast.element_field.deformation(u, fes)
state = pyrucast.element_field.integrate_behavior(model, strain, materials, dt=1e-3)
# The output becomes the next step's `prev`.
state = pyrucast.element_field.integrate_behavior(
model, strain, materials, prev=state, dt=1e-3
)
Compléments
Ce que valent les tests. Une loi visqueuse ne se contrôle pas sur une valeur
de contrainte : elle se contrôle sur le temps. D’où des tests qui vérifient
que Norton suit sa loi de vitesse en forme fermée sur un pas court, qu’une
déformation maintenue relaxe d’autant plus que le pas est long, que le taux
de Lemaitre décroît avec la déformation accumulée, que le primaire de
Blackburn sature, que la contrainte de rappel de Chaboche s’établit puis
plafonne sous C/γ, et que l’endommagement croît sans jamais guérir ni dépasser
D_c. Et que toutes refusent d’intégrer sans dt.
Tangente. Aucune de ces lois n’a de tangente analytique : toutes l’obtiennent par perturbation, comme décrit au chapitre Lois d’écoulement plastique.
Ce qui n’est pas couvert. Une seule contrainte de rappel (la version de base de Chaboche ; deux en doubleraient l’état), et pas de dépendance à la température — les paramètres sont des composantes matériau, donc ils peuvent varier dans l’espace, mais ils ne sont pas fonction du champ thermique.
Endommagement de Mazars
Modèle d’endommagement isotrope du béton (Mazars, 1986), formulation
classique à deux variables. Loi sécante : la contrainte est la contrainte
élastique (effective) affaiblie par un scalaire d’endommagement D ∈ [0, 1).
Mêmes éléments et degrés de liberté que l’élasticité linéaire
(2-D TRI3 / QUA4, 3-D TET4 / HEX8).
Équations continues résolues
- contrainte :
σ = (1 − D) · D_el : ε; - déformation équivalente :
ε̃ = √(Σ ⟨ε_I⟩₊²), somme sur les parts positives des déformations principales ; - historique :
κ = maxₜ ε̃, initialisé au seuileps_d0; l’endommagement ne croît que lorsqueκaugmente (irréversibilité, pas de guérison).
L’endommagement combine une branche traction et une branche 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, \]
(et D_c de même avec A_c, B_c). Les poids α_t, α_c proviennent de la
décomposition traction/compression de la contrainte effective
σ̃ = D_el : ε : on sépare ses contraintes principales en parts positive /
négative, on en déduit les déformations associées εᵗ, εᶜ, puis
α_t = Σ ⟨εᵗ_I⟩₊⟨ε_I⟩₊ / ε̃² (idem α_c). Le coefficient de cisaillement
β est fixé à 1.
La décomposition spectrale (déformations principales) utilise nalgebra.
Forme discrétisée
Comme la plasticité, Mazars expose deux briques, la boucle de Newton restant pilotée en Python (voir Comportement) :
- rigidité : la rigidité élastique (non endommagée)
K = ∫ Bᵀ D_el B dΩ, opérateur d’itération ; - comportement (
COMP) : la mise à jourσ,D,κpoint par point.
La boucle de Newton résout \( K\,\delta u = F_{\text{ext}} - F_{\text{int}} \) avec les forces internes endommagées
\[ F_{\text{int}} = \int_\Omega B^\top \sigma\, d\Omega, \qquad \sigma = (1 - D)\,D_{\text{el}} : \varepsilon, \]
où \( B \) est la matrice déformation-déplacement de
l’élasticité. La rigidité sécante
(élastique non endommagée) sert d’opérateur d’itération ; la loi étant sécante
et non incrémentale, seule la variable d’historique κ porte l’irréversibilité.
Le calcul interne est mené en 3-D. La déformation plane impose
ε_zz = 0 ; la contrainte plane pose ε_zz = −ν/(1−ν)(ε_xx+ε_yy) (le
facteur (1−D) se simplifie dans σ_zz = 0).
Variables et matériau
- primal :
u_x, u_y(, u_z)— dual :f_x, f_y(, f_z). - matériau :
E,nu,eps_d0(seuil),A_t, B_t(traction),A_c, B_c(compression). - état de début de pas A (entrée
prev) : la variable scalaire d’historiquekappa.Noneau premier pas — elle est plafonnée àeps_d0dans la mise à jour, donc l’absence est correcte. La contrainte effective ne dépend que de la déformation totaleε(B): la mécanique de l’endommagement n’a pas d’incrément, seulkappaest historique. - sortie du
COMP(=prevdu pas suivant) : contrainte (sigma_*),damage(le scalaireD), etkappamis à jour. - modèles :
plane_stress,plane_strain,axisymmetric(2-D) etfull_3d(3-D).
Axisymétrie
Le modèle "axisymmetric" s’applique sur une géométrie de révolution
(Coords.axisymmetric()) : Voigt à quatre
composantes [εrr, εzz, εθθ, γrz], nommées eps_xx, eps_yy, eps_zz, eps_xy
avec zz = orthoradial (convention Cast3M). Le modèle et le repère doivent
s’accorder dans les deux sens, comme en élasticité.
La déformation équivalente étant bâtie sur les déformations principales du
tenseur 3-D complet, les modèles 2-D ne diffèrent que par la reconstruction de
ce tenseur : la déformation plane force ε_zz = 0, la contrainte plane la
déduit, et l’axisymétrie lit l’orthoradiale ε_θθ = u_r/r mesurée.
Exemple Python
import pyrucast
model = pyrucast.model.mazars(fes, "plane_stress")
materials = pyrucast.element_field.material_field(
model,
[
("E", 30_000.0),
("nu", 0.2),
("eps_d0", 1e-4),
("A_t", 0.8),
("B_t", 20_000.0),
("A_c", 1.4),
("B_c", 1_900.0),
],
)
strain = pyrucast.element_field.deformation(u, fes)
state = pyrucast.element_field.integrate_behavior(
model, strain, materials, prev=prev_state
)
d = state[0].value(0, 0, "damage") # endommagement scalaire D
kappa = state[0].value(0, 0, "kappa") # variable d'historique
L’historique kappa se réinjecte au pas suivant en passant state comme
prev — la sortie le porte déjà —, ce qui garantit l’irréversibilité.
Lois d’endommagement
Introduction
L’endommagement décrit la perte de raideur d’un matériau qui se fissure, sans
déformation permanente : la contrainte est la contrainte effective, dégradée.
Comme pour la plasticité, la loi est un attribut du même modèle
([DamageLaw]) — mêmes degrés de liberté, même montage incrémental, seule change
la loi qui transforme une déformation en contrainte dégradée.
| loi | variable(s) | ce qu’elle capture |
|---|---|---|
mazars | un scalaire D | béton, deux branches mélangées |
damage_tc | d⁺, d⁻ | traction et compression séparées — l’effet unilatéral |
damage_sic_sic | d_1, d_2, d_3 | composite tissé, endommagement orthotrope |
S’y ajoute, du côté plastique, la plasticité poreuse de Gurson
(model.gurson), où l’endommagement est une porosité qui rétrécit la surface de
charge — voir plus bas.
Équations continues résolues
Les trois lois partagent le cadre de la mécanique de l’endommagement continu en variables d’état, avec la contrainte effective \( \tilde\sigma \) comme pivot :
- contrainte effective : \( \tilde\sigma = D_{\text{el}} : \varepsilon \), celle qu’aurait le matériau sain sous la même déformation ;
- dégradation : \( \sigma = (\mathbb I - \mathbb D) : \tilde\sigma \), où l’opérateur \( \mathbb D \) est un scalaire (Mazars), une paire de scalaires agissant sur des parts spectrales (Damage TC), ou un tenseur diagonal dans les axes matériau (SiC/SiC) ;
- moteur : un scalaire \( \tau \) construit sur \( \varepsilon \) ou \( \tilde\sigma \) — c’est lui qui décide ce à quoi la loi est sensible ;
- seuil et irréversibilité : une variable d’histoire \( r = \max\big(r_0,\ \max_{t’ \le t}\tau(t’)\big) \), non décroissante ;
- adoucissement : \( d = \Phi(r) \), croissante, avec \( \Phi(r_0) = 0 \).
Il n’y a pas de déformation permanente : à décharge complète, la contrainte revient à zéro par une droite de pente \( (1-d)E \). C’est ce qui distingue un endommagement d’une plasticité, et pourquoi ces lois n’ont pas de retour sur une surface — la mise à jour est explicite, en un pas, sans itérer.
Toute la variété des trois lois tient donc dans trois choix : celui du moteur \( \tau \), celui de la forme de \( \Phi \), et celui de la structure de \( \mathbb D \).
Mazars — un scalaire
Le moteur est une déformation équivalente construite sur les déformations principales positives,
\[ \tilde\varepsilon = \sqrt{\textstyle\sum_I \langle \varepsilon_I \rangle_+^2}, \qquad \kappa = \max_t \tilde\varepsilon, \]
qui répond à l’extension et est aveugle à la compression hydrostatique. Deux branches, traction et compression, partagent la même forme,
\[ D_\bullet(\kappa) = 1 - \frac{\varepsilon_{d0}(1 - A_\bullet)}{\kappa}
- \frac{A_\bullet}{\exp\big(B_\bullet(\kappa - \varepsilon_{d0})\big)}, \qquad \bullet \in {t, c}, \]
et sont mélangées par des poids issus du découpage de la contrainte effective :
\[ D = \alpha_t D_t + \alpha_c D_c, \qquad \alpha_t = \frac{\sum_I \langle\varepsilon^t_I\rangle_+ \langle\varepsilon_I\rangle_+} {\tilde\varepsilon^{\,2}}, \]
où \( \varepsilon^t \) est la déformation qu’induirait la seule part positive de \( \tilde\sigma \) (idem \( \alpha_c \) avec la part négative, et \( \alpha_t + \alpha_c = 1 \) sur un trajet proportionnel). C’est ce mélange qui permet à une seule loi de décrire un matériau un ordre de grandeur plus résistant en compression. La variable d’histoire unique étant \( \kappa \), l’endommagement ne guérit pas. Le détail — dont l’axisymétrie et la condensation en contraintes planes — est en page Endommagement de Mazars.
Damage TC — deux variables
Mélanger les deux branches en un scalaire a un coût : un matériau endommagé en compression l’est autant en traction, et le modèle ne peut pas représenter une fissure qui se referme et reprend de la charge.
Damage TC les garde séparées :
\[ \sigma = (1 - d^+)\,\tilde\sigma^+ + (1 - d^-)\,\tilde\sigma^- \]
où σ̃⁺ et σ̃⁻ sont les parties positive et négative de la contrainte
effective, découpées sur ses valeurs principales. Chacune est dégradée par sa
propre variable, si bien qu’un déchargement de la traction vers la compression
retrouve la raideur en compression — l’effet unilatéral, ce qui rend la loi
utilisable en cyclique.
Chaque endommagement 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|}, \] \[ r^\pm = \max\big(r_0^\pm,\ \max_t \tau^\pm\big), \qquad r_0^+ = \frac{f_t}{\sqrt E}, \qquad r_0^- = \frac{f_c}{\sqrt E}. \]
En traction, le moteur est l’énergie élastique stockée par la part positive.
En compression, c’est une mesure octaédrique — contrainte normale
\( \tilde\sigma^-{\text{oct}} = \tfrac13\operatorname{tr}\tilde\sigma^- \) et
cisaillement \( \tilde\tau^-{\text{oct}} \) — qui répond au confinement, et
non à la seule extension. Le coefficient K = 0,171 est la valeur usuelle,
déduite du rapport des résistances biaxiale et uniaxiale.
Les deux lois d’adoucissement sont distinctes, et c’est délibéré :
\[ d^+ = 1 - \frac{r_0^+}{r^+}\, \exp\!\Big[A_t\Big(1 - \frac{r^+}{r_0^+}\Big)\Big], \] \[ d^- = 1 - \frac{r_0^-}{r^-}(1 - A_c)
- A_c \exp\!\Big[2\Big(1 - \frac{r^-}{r_0^-}\Big)\Big]. \]
Adoucissement exponentiel en traction — une fissure est fragile, la
contrainte tombe dès le pic ; forme durcissante puis adoucissante en
compression — le béton s’écrase avec un plateau, il ne casse pas net. A_t
règle la pente de la première, A_c la résistance résiduelle de la seconde.
SiC/SiC — endommagement orthotrope
Un composite SiC/SiC est une matrice de carbure de silicium renforcée par des torons de fibres, généralement tissés. Il ne rompt ni comme un métal ni comme un béton : la matrice fissure d’abord, dans des plans normaux aux directions de torons, tandis que les fibres continuent de porter la charge à travers ces fissures. La raideur chute donc par direction, et très inégalement.
Aucun endommagement scalaire ne peut exprimer cela. Cette loi porte un endommagement par direction matériau :
\[ \kappa_i = \max_t \big\langle \varepsilon^{\text{mat}}{ii} \big\rangle+, \qquad d_i = d_{\max,i}\Big(1 - e^{-(\kappa_i - \varepsilon_{0,i})/\varepsilon_{c,i}}\Big) \quad \text{si } \kappa_i > \varepsilon_{0,i}, \]
où \( \varepsilon^{\text{mat}} = R^\top \varepsilon\,R \) est la déformation dans les axes matériau. Chaque direction a son seuil de première fissuration \( \varepsilon_{0,i} \), sa vitesse de saturation \( \varepsilon_{c,i} \) et son plafond \( d_{\max,i} \). La partie positive est essentielle : une fissure de matrice s’ouvre en extension et se referme en compression, si bien qu’une direction comprimée n’est pas dégradée du tout.
La dégradation s’applique dans ces mêmes axes, terme par terme :
\[ \sigma^{\text{mat}}{ij} = \sqrt{1 - d_i}\,\sqrt{1 - d_j}\; \tilde\sigma^{\text{mat}}{ij}, \qquad \sigma = R\,\sigma^{\text{mat}}\,R^\top . \]
Le produit \( \sqrt{1-d_i}\sqrt{1-d_j} \) garde l’opérateur symétrique et dégrade un terme de couplage autant que la plus faible des deux directions qu’il couple ; chaque cisaillement prend la paire qu’il cisaille. À \( d_i = d_j = d \) on retrouve exactement le facteur scalaire \( (1-d) \).
Le repère est le tissage
Les directions d’endommagement sont les axes matériau, fournis exactement
comme pour l’élasticité orthotrope — par les vecteurs V1,
V2 du champ matériau. Ce n’est pas une coïncidence : pour un composite tissé
ce sont les directions de torons, et réutiliser le même repère donne
gratuitement les bonnes directions maille par maille sur une pièce courbe.
Saturation, pas rupture
Chaque d_i sature à d_max,i plutôt que d’atteindre 1. C’est l’énoncé physique
que la fissuration matricielle ne prend pas toute la raideur : les fibres
restent, et un composite saturé porte encore le long de ses torons. Une loi
laissant l’endommagement atteindre 1 prédirait un effondrement qui n’a pas lieu.
Gurson — la porosité comme endommagement
Un métal ductile ne rompt pas en atteignant une contrainte : il rompt parce que
des cavités germent, croissent et coalescent jusqu’à ce que les ligaments entre
elles ne portent plus. La surface de Gurson fait de la porosité f une variable
interne explicite, qui rétrécit la surface de charge :
\[ \Phi = \left(\frac{q}{\sigma_y}\right)^2 + 2q_1 f^* \cosh\!\left(\frac{3q_2\sigma_m}{2\sigma_y}\right) - (1 + q_3 f^{*2}) \]
À f = 0 cela redonne exactement von Mises. Le cosh rend la contraction
dépendante de la contrainte hydrostatique : les cavités croissent en traction
triaxiale et se referment en compression. C’est cette sensibilité à la pression
qui fait qu’une loi J2 ne peut jamais prédire une rupture ductile.
Coalescence — au-delà d’une porosité critique les cavités coalescent et
l’effondrement s’accélère. Tvergaard et Needleman le modélisent en donnant à la
surface une porosité effective \( f^* \), bilinéaire en f :
\[ f^* = \begin{cases} f & \text{si } f \le f_c,\\ f_c + \big(\tfrac{1}{q_1} - f_c\big)\dfrac{f - f_c}{f_f - f_c} & \text{sinon,} \end{cases} \]
qui atteint \( 1/q_1 \) — surface réduite à rien, \( \Phi \equiv 0 \) — à la porosité de rupture \( f_f \). La pente au-delà de \( f_c \) est l’accélération de la coalescence ; sans elle le modèle prédit une ductilité très supérieure à la réalité.
Croissance — la conservation de la masse, et rien de plus :
\[ \dot f = (1 - f)\,\operatorname{tr}\dot\varepsilon^p . \]
L’écoulement plastique sur cette surface n’est donc pas isochore — c’est exactement ce qui le distingue de von Mises, et ce qui rend la croissance possible. Le retour se fait par plan sécant à normale numérique, comme pour Ottosen, la porosité étant remise à jour à chaque itération à partir de la part volumique de l’incrément plastique. La germination n’est pas modélisée : seule la croissance depuis une porosité initiale \( f_0 \).
Mise en donnée (Rust, testé)
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::integrate, deformation, material_field};
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), None)?;
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), Some(&damaged.field))?;
let intact = cube.step(&uniaxial(-1e-4), None)?;
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(())
}
Exemple Python
model = pyrucast.model.damage_tc(fes, "full_3d")
materials = pyrucast.element_field.material_field(
model,
[
("E", 30_000.0),
("nu", 0.2),
("f_t", 3.0),
("f_c", 30.0),
("A_t", 0.9),
("A_c", 0.5),
],
)
strain = pyrucast.element_field.deformation(u, fes)
state = pyrucast.element_field.integrate_behavior(model, strain, materials)
# `state` carries d_plus, d_minus, r_plus, r_minus — and becomes the next step's `prev`.
Compléments
Ce que valent les tests. Chaque loi est épinglée sur ce qu’elle fait et que les autres ne font pas : une fissure qui se referme reprend toute la charge en compression (Damage TC), l’écrasement laisse l’endommagement de traction intact, l’étirement selon un axe de tissage n’endommage que cette direction et sature à son plafond (SiC/SiC), et la porosité de Gurson croît sous traction triaxiale sans jamais décroître.
Une subtilité relevée en écrivant les tests. Sous une déformation purement
compressive, les poids α de Mazars s’annulent et la loi ne rapporte aucun
endommagement — propriété connue du modèle, et la raison même pour laquelle une
variable compressive séparée vaut son coût.
État absent ≠ état nul. Une loi dont l’état démarre d’une constante matériau — la porosité initiale de Gurson — doit pouvoir distinguer « pas encore d’état » de « état à zéro ». Le premier pas passe donc un vecteur de variables vide, et non un vecteur de zéros. Sans cela un métal poreux démarrerait dense et ne s’endommagerait jamais.
Pas de tangente cohérente pour les lois d’endommagement : comme Mazars, elles
ne déclarent pas de tangent_layout, et l’opérateur d’itération reste la rigidité
élastique non endommagée. Gurson, qui est une plasticité, en a une (numérique).
Poutre d’Euler-Bernoulli
Introduction
La théorie classique des poutres : les sections planes restent planes et
normales à l’axe déformé, si bien que la rotation de section est la pente,
θ = w', et qu’il n’y a aucun cisaillement transverse. C’est là toute la
différence avec Timoshenko, le portique 2D et le
cadre 3D, qui conservent une souplesse de cisaillement.
Trois configurations partagent la même théorie, et la dimension du maillage les départage — il n’y a rien à choisir :
Coords | DDL par nœud | matériau | ce qui s’ajoute à la flexion |
|---|---|---|---|
| 1-D | w, theta | E, I | rien — flexion pure |
| 2-D | u_x, u_y, r_z | + A | l’effort axial, et une rotation vers les axes globaux |
| 3-D | 6 DDL | + I_y, I_z, J, G | l’axial, la torsion, et la flexion selon deux axes principaux |
Ce fut un argument, qui ne pouvait prendre que la valeur correspondant au
maillage : toute autre était refusée. Un argument à valeur unique ne transporte
aucune information — il n’offre qu’un moyen de se contredire — donc la
configuration se déduit. Les noms de DDL obtenus se relisent par
model.primal_vars().
Équations continues résolues
La cinématique est celle d’une section rigide qui tourne avec la pente :
\[ u_x(x, y, z) = u(x) - y\,w’(x), \qquad u_y = w(x), \qquad \theta = w’ . \]
D’où une déformation axiale affine dans la section, et aucune distorsion :
\[ \varepsilon_{xx} = u’ - y\,w’’ = \varepsilon_0 - y\,\chi, \qquad \chi = w’’, \qquad \gamma_{xy} \equiv 0 . \]
\( \chi \) est la courbure. En intégrant \( \sigma_{xx} = E\varepsilon_{xx} \) sur la section, les efforts généralisés se découplent — c’est le choix de l’axe neutre, \( \int_A y\,dA = 0 \) — et la loi de section s’écrit
\[ N = EA\,\varepsilon_0, \qquad M = EI\,\chi, \qquad I = \int_A y^2\,dA . \]
L’équilibre local intégré sur la section donne alors les deux équations classiques, découplées elles aussi :
\[ (EA\,u’)’ + n = 0, \qquad (EI\,w’‘)’’ = q . \]
La seconde est du quatrième ordre — et c’est toute la différence avec Timoshenko, qui en fait deux du second ordre en gardant \( \theta \) indépendant de \( w’ \). En 3-D s’y ajoutent la flexion selon le second axe principal (\( M_z = EI_z\,w_y’’ \)) et la torsion de Saint-Venant \( M_t = GJ\,\varphi’ \), qui ne se couple à rien pour une section symétrique.
Pourquoi une physique à part, et non une aire de cisaillement infinie
On pourrait atteindre Bernoulli en faisant tendre G·A_s → ∞ dans un élément de
Timoshenko, et le résultat serait juste en arithmétique exacte. En virgule
flottante il ne l’est pas : le terme de cisaillement domine alors la raideur de
plusieurs ordres de grandeur et la réponse en flexion s’y noie — le blocage en
cisaillement classique, atteint par l’autre bout.
Écrire la théorie directement supprime la question, et supprime au passage deux
constantes matériau (G, A_s) qu’un modèle de Bernoulli n’a aucune raison de
demander. Réclamer une constante qu’une théorie n’utilise pas, c’est inviter la
mauvaise.
L’élément
L’équation étant du quatrième ordre, sa forme faible demande une interpolation
\( C^1 \) : le déplacement et sa pente doivent être continus d’un élément
au suivant. C’est exactement ce que fournit la famille
Hermite3,
qui prend pour degrés de liberté la flèche et la pente à chaque extrémité — deux
fonctions de forme par nœud au lieu d’une.
Ce n’est donc pas une interpolation de Lagrange, et le modèle exige un
espace HERMITE3 :
fes = pyrucast.FiniteElementSpace(maillage, interpolation="HERMITE3")
poutre = pyrucast.model.bernoulli(fes) # 1-D ⇒ flexion pure
Un espace de Lagrange porterait une flèche linéaire, de courbure identiquement nulle : le modèle le refuse plutôt que d’assembler une raideur qui ne correspondrait pas à la base déclarée.
La courbure est alors linéaire sur l’élément, donc l’espace d’approximation contient exactement la solution d’une travée chargée seulement à ses extrémités : l’élément est exact aux nœuds pour toute charge laissant la travée libre d’efforts répartis — c’est pourquoi un élément par barre suffit pour un portique. La raideur \( K_b = \int_0^L EI\,N’‘^\top N’’\,dx \) est intégrée depuis cette base, et vaut exactement la forme fermée classique :
\[ K_b = \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}, \qquad \text{DDL } [\,w_A,\ \theta_A,\ w_B,\ \theta_B\,]. \]
L’intégration plutôt que la forme fermée est un choix : elle laisse une seule
source de vérité, et rend l’interpolation déclarée porteuse. Une base fausse
produirait désormais une raideur fausse, que les tests de poutre attraperaient ;
avec une matrice écrite en dur, elle aurait pu être n’importe quoi. La forme
fermée reste, comme oracle de test — c’est elle que tests/hermite.rs
compare à l’intégrale, à la précision machine.
L’effort axial y est ajouté par le terme de barre \( EA/L \), qui ne s’y couple pas.
Le repère local 3-D est déduit automatiquement d’une référence globale Z (globale Y pour une barre quasi verticale), comme pour le cadre 3D : aucune donnée d’orientation à fournir, ce qui convient aux sections symétriques.
Variables et matériau
Le comportement (COMP) rend les efforts de section — M en 1-D, N, M en
plan, N, M_y, M_z, T dans l’espace — par une loi linéaire, comme tout élément
structural.
Elle n’en rend aucun de cisaillement, et la liste s’arrête donc plus tôt que
celle de Timoshenko. Ce n’est pas une omission : Euler-Bernoulli
est Φ = 0, donc les lignes de cisaillement de son B sont nulles, et il n’y
a rien à leur apparier. Les deux absences sont un seul énoncé — c’est aussi
pourquoi cette théorie n’a pas à porter Φ dans son état, là où l’autre le doit.
Les forces internes intègrent le transposé du même B
(models::beam::b_into) que la rigidité : ∫ Bᵀσ vaut K·u exactement, la loi
étant linéaire. C’est ce que mesure tests/internal_forces.rs, dans les trois
configurations.
Mise en donnée (Rust, testé)
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::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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))?);
let multiplier = mesh::barycenter(&imposed)?;
model = model.union(&model::dirichlet(
&model,
var,
&imposed,
&multiplier,
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(())
}
Exemple Python
model = pyrucast.model.bernoulli(fes) # Coords 2-D ⇒ portique plan
materials = pyrucast.element_field.material_field(
model, [("E", 210_000.0), ("A", 1e-2), ("I", 1e-4)]
)
k = pyrucast.matrix.stiffness(model, materials)
Compléments
Masse et rigidité géométrique. La masse cohérente vient du même bloc que
celui de Timoshenko, pris à Φ = 0 : Bernoulli
n’apporte donc aucune dérivation propre, il est le bout sans cisaillement d’une
seule. Ce que ce bloc rend à Φ = 0 est la table classique
ρAL/420·[156, 22L, 54, −13L ; …], ce qu’un test affirme.
La rigidité géométrique demande un effort axial pour raidir la barre : la configuration 1-D, en flexion pure, n’en déclare donc aucune — elle ne contribue rien plutôt que d’erroner, un modèle qui ne déclare pas un genre étant simplement ignoré de l’assembleur.
Ce que valent les tests. Un élément de poutre gagne sa place en étant exact
aux nœuds : les tests comparent donc aux formules du cours à la précision
machine (1e-12), et non à une tolérance de discrétisation. Console sous charge
en bout (PL³/3EI), sous moment en bout (ML²/2EI — un cas qu’un signe faux
dans la matrice d’Hermite raterait tout en passant le premier), traction axiale
découplée de la flexion, et torsion TL/GJ.
Quand préférer Timoshenko. Un dernier test mesure ce qui sépare les deux théories : une poutre élancée donne le même résultat aux deux (à 1 % près), une poutre trapue fléchit nettement plus avec le cisaillement. Bernoulli est exactement la théorie qui dit qu’elle ne le fait pas — à utiliser tant que l’élancement le permet, et à quitter sinon.
Ce test-là maille les deux poutres. Bernoulli est exact avec un seul élément, mais l’interpolation linéaire de Timoshenko ne l’est pas : comparer les deux théories sur un élément unique mesurerait le maillage, pas la physique.
Poutre de Timoshenko
Poutre déformable en cisaillement : élément SEG2, dans la configuration
que lui donne la dimension du maillage. C’est une seule physique — elle
remplace les anciens Model.frame (portique plan) et Model.frame3d (cadre
spatial), qui en étaient les cas 2-D et 3-D.
Coords | DDL par nœud | matériau | efforts de section |
|---|---|---|---|
| 1-D | w, theta | E, I, G, A_s | M, V |
| 2-D | u_x, u_y, r_z | + A | N, M, V |
| 3-D | six | E, A, I_y, I_z, J, G, A_sy, A_sz | N, M_y, M_z, T, V_y, V_z |
Il n’y a rien à choisir : tout ce qui distingue les trois — nombre et noms des
DDL, jeu matériau, efforts rendus, présence d’un terme axial, d’une torsion,
d’une rotation vers les axes globaux — découle de la dimension. On relit les
noms obtenus par model.primal_vars().
Équations continues résolues
La section reste plane mais non normale à l’axe déformé : la rotation θ
est un champ indépendant, et la distorsion γ = w' − θ une déformation à part
entière. C’est toute la différence avec
Euler-Bernoulli, où θ = w' et où γ n’existe pas.
- cinématique : courbure \( \kappa = \theta’ \), distorsion \( \gamma = w’ - \theta \) ;
- efforts : \( M = EI,\theta’ \), \( V = G A_s (w’ - \theta) \) ;
- équilibre : \( V’ + q = 0 \), \( M’ - V = 0 \).
Ces deux équations sont du second ordre, là où Bernoulli en a une seule du
quatrième. C’est la contrepartie de l’hypothèse cinématique : libérer θ de
w' abaisse l’ordre de l’équation, et abaisse avec lui l’exigence de continuité
— le C⁰ suffit là où Bernoulli réclame du C¹.
Forme discrétisée — l’élément exact
L’élément assemblé est la solution exacte de ces deux équations sur une
travée libre d’efforts répartis. Ses fonctions de forme sont cubiques en w et
quadratiques en θ, et elles portent le matériau par
\[ \Phi = \frac{12,E I}{G A_s L^2}, \]
le rapport des souplesses de flexion et de cisaillement. La flexion s’écrit alors en forme fermée :
\[ K_b = \frac{EI}{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}. \]
L’élément est exact aux nœuds pour des charges d’extrémité : un élément par barre suffit. On lit directement sur cette matrice que la raideur en flèche est la combinaison en série des deux souplesses,
\[ K_{ww} = \frac{12EI}{L^3(1+\Phi)} = \frac{1}{\dfrac{L^3}{12EI} + \dfrac{L}{G A_s}}, \]
— on fléchit le tronçon et on le cisaille, les deux cèdent l’un après l’autre. Et \( \Phi = 0 \) redonne terme pour terme la matrice d’Euler-Bernoulli : « Bernoulli est la limite sans cisaillement » est une propriété vérifiée par un test, pas une phrase.
L’espace EF ne porte aucune base
Ces fonctions de forme dépendent du matériau par \( \Phi \). Aucun espace
éléments finis ne peut donc les tabuler — il tabule par type d’élément, pas par
maille. L’espace déclare en conséquence
MODEL_EMBEDDED : la
formulation possède son interpolation, et le dit.
fes = pyrucast.FiniteElementSpace(maillage, interpolation="MODEL_EMBEDDED")
poutre = pyrucast.model.timoshenko(fes)
Ce que remplace cet élément. La version précédente était linéaire, à cisaillement sous-intégré : elle convergeait au raffinement au lieu d’être exacte, et déclarait une interpolation de Lagrange qu’elle utilisait réellement. Le portique 2-D était dans ce cas, le cadre 3-D employait déjà la forme exacte — deux modèles frères, deux théories discrètes. Ils n’en font plus qu’une.
Variables et matériau
Voir le tableau d’ouverture. rho est facultatif, exigé par la seule
matrice de masse ; en configuration 1-D l’aire pleine A l’est aussi, la
rigidité n’utilisant que l’aire de cisaillement.
Le comportement (COMP) rend les efforts de section par une loi linéaire,
à partir des déformations généralisées produites par
beam_deformation, un opérateur pour les trois configurations.
Mise en donnée (Rust, testé)
Console encastrée, charge transverse P au bout libre ; solution analytique
w = P·L³/(3EI) + P·L/(G·A_s) — les deux souplesses, en série.
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::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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))?);
let multiplier = mesh::barycenter(&imposed)?;
model::dirichlet(target, var, &imposed, &multiplier, 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::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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))?);
let multiplier = mesh::barycenter(&imposed)?;
model::dirichlet(target, var, &imposed, &multiplier, 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(())
}
Et 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::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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))?);
let multiplier = mesh::barycenter(&imposed)?;
model::dirichlet(target, var, &imposed, &multiplier, 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(())
}
Exemple Python
"""Poutre de Timoshenko — console exacte dès un seul élément.
Physique
--------
Poutre déformable en cisaillement. Cinématique : courbure `κ = θ'`, distorsion
`γ = w' - θ`. Efforts : moment `M = E·I·θ'`, effort tranchant
`V = G·A_s·(w' - θ)`. Équilibre : `dV/dx + q = 0`, `dM/dx - V = 0`.
The assembled element is the **exact solution** of these two equations on a
span free of distributed loads — the closed form parameterized by
`Φ = 12·E·I/(G·A_s·L²)`. Its shape functions therefore depend on the material,
which no finite element space can tabulate: the space declares
`MODEL_EMBEDDED`, that is, the formulation owns its interpolation.
Problème
--------
Clamped cantilever (`w = θ = 0`), transverse load `P` at the free end.
Analytical solution `w = P·L³/(3·E·I) + P·L/(G·A_s)` — both compliances,
cisaillement, **en série**.
The element being exact at the nodes, **one** is enough: refining changes
nothing, which this script checks. (The previous version was linear with
under-integrated shear; it converged towards that value instead of reaching
it, and this example showed its convergence.)
Lancement ::
maturin develop --features extension-module
python examples/timoshenko.py
"""
import pyrucast
E, I, G, A_S, L, P = 1.0, 1.0, 30.0, 1.0, 1.0, 1.0
def _clamp(target, node, var):
imposed = pyrucast.mesh.poi1_from_nodes([node])
multiplier = pyrucast.mesh.barycenter(imposed)
return pyrucast.model.dirichlet(target, var, imposed, multiplier)
def tip_deflection(n_elems: int) -> float:
c = pyrucast.Coords(1)
base = c.add_node([0.0])
tip = c.add_node([L])
mesh = pyrucast.mesh.line(base, tip, n_elems) # console 1-D (`line`)
# The basis belongs to the formulation, not to the space: it depends on `Φ`,
# hence on the material, and is computed cell by cell.
fes = pyrucast.FiniteElementSpace(mesh, interpolation="MODEL_EMBEDDED")
model = pyrucast.model.timoshenko(fes)
model = model | _clamp(model, base, "w")
model = model | _clamp(model, base, "theta")
materials = pyrucast.element_field.material_field(
model, [("E", E), ("I", I), ("G", G), ("A_s", A_S)]
)
load = pyrucast.mesh.poi1_from_nodes([tip])
rhs = pyrucast.NodeField(load, ["f_w"])
rhs[0].set_value(tip, "f_w", P)
solution = pyrucast.solver.solve(pyrucast.matrix.stiffness(model, materials), rhs)
return solution.value(tip, "w")
def main() -> None:
analytical = P * L**3 / (3.0 * E * I) + P * L / (G * A_S)
print(f"{'N':>4} {'w_tip':>12} {'err. rel.':>12}")
for n in (1, 2, 5, 10, 40):
w = tip_deflection(n)
print(f"{n:4d} {w:12.6f} {abs(w - analytical) / analytical:12.2e}")
print(f"\nanalytique = {analytical:.6f} (P·L³/3EI + P·L/GA_s)")
# Exact at the nodes: one element already gives the answer, and refining does
# not improve it — there is nothing to improve.
one = tip_deflection(1)
assert abs(one - analytical) < 1e-12 * analytical, one
assert abs(tip_deflection(40) - one) < 1e-12 * analytical
print("OK: exact at the nodes from one element on, refining changes nothing.")
if __name__ == "__main__":
main()
Compléments
Masse. La masse cohérente est celle du même élément, intégrée des mêmes fonctions de forme que sa rigidité :
\[ M = \int_0^L \rho A\, N_w^\top N_w\, dx
- \int_0^L \rho I\, N_\theta^\top N_\theta\, dx, \]
le second terme étant l’inertie de rotation de la section. Rigidité et masse
décrivent enfin une seule poutre. Seuls l’axial et la torsion gardent la forme
du champ linéaire (ρL/6)[[2,1],[1,2]], exacte pour ce qu’ils interpolent
réellement.
Elle est intégrée, et non recopiée de la table publiée de polynômes en Φ.
Cette table est juste, mais un coefficient mal retranscrit sur vingt donnerait
une matrice plausible, symétrique et définie positive — décrivant une autre
poutre. C’est le mode de défaillance qui avait coûté une tangente fausse plus
tôt dans ce projet. Une intégration ne se retranscrit pas : les fonctions de
forme sont celles de la rigidité, et quatre points de Gauss rendent la
quadrature exacte (l’intégrande est de degré 6).
Ce qui l’épingle : à Φ = 0 elle redonne la table classique
ρAL/420 · [156, 22L, 54, −13L ; …], douze nombres que personne ne conteste ;
une translation rigide porte exactement ρAL quel que soit Φ ; et le
couplage flèche-rotation, absent de la masse linéaire, est bien là.
Rigidité géométrique. Elle demande un effort axial pour raidir la barre : la configuration 1-D, en flexion pure, n’en déclare donc aucune.
Reconstruction des efforts. beam_deformation évalue les déformations
à chaque point de Gauss, depuis les fonctions de forme de l’élément — les
mêmes que sa rigidité et sa masse. Ce qu’elle rend dit alors la physique :
- la courbure varie linéairement, donc le moment aussi, ce qu’impose
M' = V; - le cisaillement est constant, ce qu’impose
V' = 0sur une travée non chargée.
L’élément linéaire ne pouvait rendre ni l’un ni l’autre : sa courbure était constante et son cisaillement oscillait, d’où une moyenne pour tout.
L’opérateur exige le matériau, et c’est la signature honnête : Φ en
dépend, donc la distribution de courbure aussi. On ne reconstitue pas la
courbure d’une poutre sans connaître sa raideur de cisaillement.
Le résidu, et pourquoi Φ est dans l’état. Les forces internes intègrent le
transposé du même B (models::beam::b_into), si bien que ∫ Bᵀσ vaut
K·u exactement — la loi de section étant linéaire. Mais le noyau qui les
calcule reçoit la géométrie et l’état, jamais le matériau : le B d’un
continuum est le gradient symétrique et ignore tout module, ce seam n’a donc
jamais eu à en porter un. Le comportement rend par conséquent Φ (phi, ou
phi_y et phi_z en spatial) à côté des efforts de section. C’est la seule
grandeur non conjuguée que ce dépôt garde en état, et elle le mérite : le résidu
la relit à chaque itération de Newton.
Le repère local, lui, est déduit automatiquement de la géométrie (référence globale Z, ou Y pour une barre verticale), ce qui convient aux sections symétriques.
Le repère de rotation. Le portique plan nommait sa rotation rz quand tout
le reste du dépôt écrivait r_z. La fusion l’a fait sortir immédiatement — une
matrice non carrée au solveur — et c’est r_z qui l’emporte.
Coques
Introduction
Une coque est une surface qui porte à la fois des efforts de membrane et des
moments de flexion. Ses éléments sont des variétés (ref_dim = 2 dans un espace
3-D) — précisément le cas que refuse le garde-fou des milieux continus : un noyau
massif construirait B à partir du gradient tangentiel et serait déficient en
rang à travers l’épaisseur. Une coque a son noyau et sa cinématique propres.
Comme partout ailleurs ici, la formulation est un attribut (ShellModel) :
les DDL, le repère local, la loi de membrane et la rotation vers les axes globaux
sont partagés, seul le traitement flexion/cisaillement change.
| formulation | cisaillement transverse | éléments |
|---|---|---|
thick (Reissner-Mindlin) | oui, intégré réduit | TRI3, QUA4 |
kirchhoff (DKT/DKQ) | imposé nul en des points discrets | TRI3, QUA4 |
Membrane et vrillage sont une seule routine partagée : ils ne doivent rien à la théorie de flexion, et un test vérifie que les deux formulations donnent bien le même allongement de membrane.
Six DDL par nœud, et celui de vrillage
La cinématique naturelle d’une coque compte cinq degrés de liberté — trois
translations et deux rotations de la fibre normale. Mais le cinquième et le
sixième ne se distinguent que dans le repère local, alors qu’un assembleur
global numérote les DDL par leur nom. L’élément en porte donc six,
u_x…u_z, r_x…r_z, exactement comme le cadre 3D — ce qui permet
aussi à une coque et à un portique spatial de partager des nœuds sans adaptateur.
Le sixième, la rotation autour de la normale, est le DDL de vrillage, et une facette plane n’oppose aucune raideur physique à son sujet : laissé seul, il rend la matrice élémentaire singulière. Il est donc lié à la rotation propre de la membrane,
\[ \omega_z = \tfrac12\left(\frac{\partial v}{\partial x} - \frac{\partial u}{\partial y}\right), \qquad K_\text{vrillage} = \alpha\,G h \int (\theta_z - \omega_z)^2\, dA \]
ce qui est un énoncé physique (la rotation de vrillage doit suivre celle de la matière) et non une béquille numérique. Une pénalité diagonale serait le raccourci tentant, et serait fausse : elle s’oppose à une rotation rigide de la facette autour de sa normale, qui ne coûte aucune énergie. C’est ce que vérifie un test.
Et puisque cette contrainte travaille, elle a un effort conjugué : le
comportement rend M_drill = α·G·h·(θ_z − ω_z) au même titre que N_xx ou
M_xx. Il a longtemps manqué à la liste — non par choix, mais parce que
personne n’avait encore demandé à une coque son résidu. Un ∫ Bᵀσ qui
l’omettrait serait faux du terme même qui désingularise l’élément.
Le repère local est par élément
Les DDL nodaux étant globaux, la rotation local → global doit être une matrice par élément, pas par point de Gauss. Elle est construite depuis les coordonnées des nœuds — la première arête, et la normale de la facette — ce qui la rend exacte pour une facette plane et raisonnable pour un quadrilatère légèrement gauche.
Les dérivées locales, elles, ne coûtent rien : le gradient tangentiel
CellGeom::dn_dx est déjà dans le plan tangent, donc le projeter sur e₁, e₂
est la dérivée locale — aucune inversion, aucun second jacobien.
Reissner-Mindlin (thick)
Équations continues résolues
La fibre normale reste droite mais non normale. Sa rotation est un champ indépendant, ce qui donne une cinématique affine dans l’épaisseur (\( z \in [-h/2,\ h/2] \)) :
\[ u_x(x,y,z) = u(x,y) + z\,\theta_y, \quad u_y(x,y,z) = v(x,y) - z\,\theta_x, \quad u_z = w(x,y). \]
Les déformations s’y séparent en trois familles — membrane, flexion et cisaillement transverse :
\[ \varepsilon = \begin{bmatrix} \partial u/\partial x \\ \partial v/\partial y \\ \partial u/\partial y + \partial v/\partial x \end{bmatrix}, \quad \kappa = \begin{bmatrix} \partial \theta_y/\partial x \\ -\,\partial \theta_x/\partial y \\ \partial \theta_y/\partial y - \partial \theta_x/\partial x \end{bmatrix}, \quad \gamma = \begin{bmatrix} \partial w/\partial x + \theta_y \\ \partial w/\partial y - \theta_x \end{bmatrix}, \]
la déformation dans le plan à la cote z valant
\( \varepsilon(z) = \varepsilon + z\,\kappa \). C’est \( \gamma \) qui fait
toute la différence avec Kirchhoff-Love, où l’on impose \( \gamma = 0 \), donc
\( \theta = -\nabla w \), et où la courbure redevient un jeu de dérivées
secondes de la seule flèche.
Les lois de section
L’intégration dans l’épaisseur d’un matériau homogène en contraintes planes découple les trois familles et donne, avec \( N = \int \sigma\,dz \), \( M = \int z\,\sigma\,dz \) et \( T = \int \tau\,dz \) :
\[ D_m = \frac{Eh}{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 . \]
La loi de flexion est celle de membrane multipliée par \( h^2/12 \) : c’est tout le contenu de « les sections restent planes » — le même matériau en contraintes planes, intégré dans l’épaisseur avec un poids \( z^2 \) (\( \int_{-h/2}^{h/2} z^2 dz = h^3/12 \)).
Le facteur \( k_s = 5/6 \) corrige le fait que la cinématique impose un
cisaillement uniforme dans l’épaisseur là où la solution exacte est
parabolique et nulle aux peaux ; il est choisi pour restituer la bonne énergie de
cisaillement d’une section rectangulaire, et se règle par la composante matériau
k_s.
La raideur élémentaire est alors la somme des trois formes,
\[ K_e = \int_A \Big( B_m^\top D_m B_m + B_b^\top D_b B_b + B_s^\top D_s B_s \Big)\,dA, \]
chacune avec sa quadrature — et c’est là le point suivant.
Pourquoi le cisaillement est intégré réduit
À mesure que la coque s’amincit, D_s (linéaire en h) écrase D_b (cubique en
h) d’un facteur 1/h². Intégré à la quadrature complète, le terme de
cisaillement impose alors γ = 0 point par point, ce qu’un élément linéaire
ne peut satisfaire qu’en refusant de fléchir : le déplacement s’effondre vers
zéro et aucun raffinement ne le récupère. C’est le blocage en cisaillement.
L’intégrer en un seul point relâche la contrainte en moyenne, l’élément fléchit, et le calcul converge. La poutre de Timoshenko a connu le même blocage et y a répondu de la même manière ; elle a depuis été remplacée par un élément exact, qui possède son interpolation au lieu d’en intégrer une. La coque épaisse est donc aujourd’hui le seul élément multi-quadrature du code : le patron reste général, son utilisateur ne l’est plus.
Et le second sous-espace n’est pas un argument de model.shell : rien en lui
n’appartient à l’appelant (même sous-maillage, même interpolation, seule la
quadrature change), et element_matrix lit les deux CellGeom comme une seule
maille — un invariant qu’il vaut mieux établir par construction que valider après
coup. Le choix qui est réel, lui, est bien un argument : la formulation.
Le remède alternatif est de ne pas avoir de contrainte du tout — c’est le Kirchhoff discret, ci-dessous, qui n’a rien à bloquer.
Kirchhoff discret (kirchhoff)
Ce que dit la théorie, et pourquoi on ne l’écrit pas telle quelle
Kirchhoff-Love impose que la fibre normale reste normale : \( \gamma = \nabla w + \beta = 0 \), donc \( \beta = -\nabla w \) et la courbure redevient un jeu de dérivées secondes de la seule flèche :
\[ \kappa = \begin{bmatrix} -\,\partial^2 w/\partial x^2 \\ -\,\partial^2 w/\partial y^2 \\ -\,2\,\partial^2 w/\partial x \partial y \end{bmatrix}. \]
C’est une équation d’ordre quatre, et un élément conforme pour elle réclame une base C¹ — la poutre de Bernoulli en dimension un, où le cubique d’Hermite la fournit. En dimension deux, la même construction (le bicubique d’Hermite, quatre DDL par nœud dont le vrillage \( \partial^2 w/\partial x \partial y \)) n’est conforme que sur des rectangles alignés aux axes : ailleurs le jacobien varie, la conversion des DDL nodaux diffère d’un élément à l’autre au nœud partagé, et la continuité C¹ est perdue. Aucun mailleur d’ici ne produit une telle grille.
Ce qu’on écrit à la place
La réponse du Kirchhoff discret est de garder la rotation comme champ interpolé et de n’imposer \( \gamma = 0 \) qu’en des points choisis. Rien n’est jamais dérivé deux fois, la base reste Lagrange, et la limite mince est exacte par construction plutôt qu’approchée depuis un cisaillement qu’il faudrait empêcher de bloquer.
La rotation \( \beta \) est interpolée quadratiquement — les six fonctions
d’un TRI6, les huit d’un QUA8 — sur un élément dont la géométrie reste
linéaire. Ses valeurs de milieu d’arête sont ensuite éliminées, arête par arête,
par trois énoncés :
| énoncé | où | ce qu’il donne |
|---|---|---|
| \( \gamma = 0 \) | à chaque sommet | \( \beta_i = -\nabla w_i \) |
| \( \gamma_s = 0 \) | au milieu de chaque arête | \( \beta_{sk} = -\tfrac{3}{2l}(w_j - w_i) - \tfrac14(\beta_{si} + \beta_{sj}) \) |
| \( \beta_n \) linéaire | le long de chaque arête | \( \beta_{nk} = \tfrac12(\beta_{ni} + \beta_{nj}) \) |
Le deuxième lit la pente à mi-portée de la cubique que suit la flèche le long d’une arête : c’est par là que l’exactitude d’une poutre d’Euler-Bernoulli entre dans une plaque, sans qu’aucune base d’Hermite soit jamais assemblée.
Après élimination, chaque fonction de milieu d’arête porte une combinaison fixe des DDL de sommet, et tout l’élément tient en cinq nombres par arête :
\[ a = \frac{-x_{ij}}{l^2}, \quad b = \frac{3}{4}\frac{x_{ij} y_{ij}}{l^2}, \quad c = \frac{\tfrac14 x_{ij}^2 - \tfrac12 y_{ij}^2}{l^2}, \quad d = \frac{-y_{ij}}{l^2}, \quad e = \frac{\tfrac14 y_{ij}^2 - \tfrac12 x_{ij}^2}{l^2}. \]
\( a \) et \( d \) portent la flèche dans la rotation — ils sont impairs dans le sens de l’arête, d’où le changement de signe entre ses deux extrémités ; \( b \), \( c \) et \( e \) projettent une rotation de sommet sur la tangente et la normale de l’arête, et sont pairs.
DKT et DKQ ne diffèrent que par le nombre de sommets, la base quadratique et la quadrature : c’est donc une seule routine, pas deux. Les tables \( H_x \), \( H_y \) publiées par Batoz pour l’un et pour l’autre en ressortent, et c’est ce que vérifie le test unitaire — l’élimination est gardée comme une matrice sur les fonctions de forme plutôt que comme le \( H_x(\xi, \eta) \) assemblé de la littérature, si bien que \( \partial H/\partial \xi = C \cdot \partial N/\partial \xi \) réutilise la même matrice et qu’aucune seconde table n’est à tenir cohérente avec la première.
Ce qu’il n’a pas
Pas de déformation de cisaillement, donc pas de \( Q \) issu d’une loi de
comportement : l’effort tranchant d’une plaque mince est une réaction,
retrouvée par le gradient des moments. Le comportement s’arrête aux sept
résultantes de membrane, de flexion et de vrillage, là où thick en rend neuf.
Un seul B, lu dans les deux sens
Les deux formulations bâtissent leurs lignes de B au même endroit
(models::shell::b_into), et ne diffèrent que par les trois de flexion — ce qui
est la différence entre elles. La rigidité en intègre Bᵀ D B, les
forces internes le transposé Bᵀ σ, puis ramènent le
résultat aux axes globaux par la transposée du trièdre de la facette.
Les déformations, elles, s’obtiennent par
shell_deformation — le produit B · u. Sa
particularité tient à la double quadrature : membrane, flexion et vrillage à
chaque point de Gauss, cisaillement transverse au seul point réduit, écrit
ensuite à tous. C’est le pendant de l’intégration réduite de la rigidité, et
c’est ce qui fait tomber ∫ Bᵀσ exactement sur K·u — ce que mesure
tests/internal_forces.rs, pour les deux formulations sur TRI3 et QUA4.
Mise en donnée (Rust, testé)
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::ShellModel;
use pyrucast::ops::mesh;
use pyrucast::ops::model;
use pyrucast::ops::solver::lu::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, ShellModel::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, ShellModel::Kirchhoff, element)?;
assert!(
(w - exact).abs() < 0.03 * exact,
"{element:?}: central deflection {w}, textbook {exact}"
);
}
Ok(())
}
Exemple Python
model = pyrucast.model.shell(fes, "thick") # ou "kirchhoff"
materials = pyrucast.element_field.material_field(
model, [("E", 210_000.0), ("nu", 0.3), ("h", 0.01)]
)
k = pyrucast.matrix.stiffness(model, materials)
Compléments
Ce que valent les tests. Une plaque carrée encastrée sous charge uniforme a
une flèche de cours, w = 0,00126·qa⁴/D : le test la retrouve à 6 % près et
vérifie que le maillage converge, par le dessous, avec des incréments qui
décroissent.
Mais le test qui compte est celui du blocage. Normalisée par la raideur de plaque, la flèche d’une plaque mince est une constante de la théorie, indépendante de l’épaisseur. Le test la mesure sur deux décades d’épaisseur : un élément qui bloque la perd de plusieurs ordres de grandeur, un élément correctement sous-intégré la conserve.
S’y ajoutent le comportement de membrane, exact à 1e-9, et le vrillage rigide
qui ne coûte pas d’énergie tout en laissant un vrillage parasite en coûter.
Pour le Kirchhoff discret, l’énoncé de non-blocage est plus tranchant
encore : la flèche normalisée n’est pas seulement stable quand la plaque
s’amincit, elle est invariante à la précision machine — l’épaisseur n’entre
dans la raideur de flexion que par un facteur \( h^3 \), et le problème de
flexion pure est donc exactement sans échelle. Le test l’exige à 1e-6 près sur
deux décades et demie, pour DKT comme pour DKQ.
La convergence, elle, ne se dit pas de la même manière : la facette de Mindlin est un modèle en déplacements compatible, donc sa flèche ne peut que monter vers la réponse. Un élément à Kirchhoff discret ne l’est pas — éliminer les rotations de milieu d’arête sous des contraintes qui ne tiennent qu’en des points laisse une interpolation discontinue au travers d’une arête, et la borne variationnelle s’en va avec. Le test vérifie donc la convergence elle-même (chaque raffinement tombe plus près, les incréments décroissent), pas le côté d’où elle arrive : sur cette plaque encastrée, la DKQ converge par le haut.
Enfin, une bascule rigide de la plaque — w = x et la rotation qui l’accompagne
— ne doit coûter aucune énergie. Assemblé, ce test attrape ce que les tables
d’élément ne peuvent pas : la convention de signe liant la flèche à la rotation
(\( \beta = -\nabla w \), donc r_y = −1 pour u_z = x), et la rotation
local → global qui la transporte.
Ce qui n’est pas couvert. Pas de matrice de masse ni de raideur géométrique pour l’instant ; pas de coque courbe au sens propre — une facette plane par élément, ce qui est la formulation usuelle des coques facettisées et demande un maillage plus fin sur une forte courbure. Aucun opérateur de déformation de coque non plus : les lois de section sont écrites et testées par la raideur, mais la reconstruction des résultantes depuis un champ solution reste à faire.
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.
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
\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 la réaction dans l’équation de la physique cible (la force qui maintient la contrainte) ; - à la solution, le multiplicateur
λest cette force de réaction.
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, fournis par
l’utilisateur via un maillage : la contrainte ne crée jamais de nœud et ne
mute jamais le Coords.
Imposition par élimination (condensation)
Voie alternative aux multiplicateurs, sélectionnée au moment de la résolution
avec solve_eliminate au lieu de solve. 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 le
même LU creux — mais sur une matrice plus petite et définie, sans DOF
multiplicateur. La solution est prolongée u = T·û + u₀ ; la réaction
(équivalent du multiplicateur) est récupérée en post-traitement, −(K·u − f) à
la ligne duale de chaque esclave (= aₛ·λ).
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 v1 : 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(barre, "u_x", imposed, mult, sense=">=")
Une relation unilatérale obéit aux conditions de complémentarité (KKT) : ou
bien elle est active (l’égalité tient et le multiplicateur porte la
réaction), 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), portée par l’opérateur
solve_unilateral :
- 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).
solution = pyrucast.solver.solve_unilateral(
k, model, rhs
) # method, cache, max_iter, tol
Les relations inactives sortent avec λ = 0 exact ; la solution a la même
forme que la voie Lagrange (primal + multiplicateurs). L’assemblage est
inchangé — le sense n’est lu que par les solveurs : solve sur un modèle
unilatéral résout la version « tout collé » (toutes les relations en égalité),
et solve_eliminate le refuse avec une erreur explicite. Un modèle sans
inégalité retombe sur le solve ordinaire.
Le second membre g (la borne) suit le mécanisme habituel : au slot
imposed_value 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 duale
propre de la contrainte (imposed_<v> pour Dirichlet, mpc_rhs pour la MPC),
au nœud-multiplicateur de la relation. Retrouver ce nœud et cette composante
à 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_unilateral.
D’autres contraintes suivront le même patron : une struct
implémentant SubModelKind, des blocs C/Cᵀ littéraux. Voir Ajouter une
physique.
Dirichlet
La condition de Dirichlet impose la valeur d’une variable primale,
u(n) = u_d, sur un ensemble de nœuds. C’est une contrainte
imposée par multiplicateurs de Lagrange : aucun matériau, aucune loi de
comportement. Elle ne crée aucun nœud et ne mute jamais le Coords.
Implémentation : src/models/dirichlet.rs ; opérateur ops::model::dirichlet(…).
Deux maillages fournis par l’utilisateur
L’utilisateur fournit deux maillages POI1 :
imposed_mesh— les nœuds contraints (partagés avec la physique cible) ;multiplier_mesh— le support des multiplicateurs, apparié élément-par-élément avecimposed_mesh(même structure de sous-maillage, même nombre de cellules par paire).
On fabrique typiquement multiplier_mesh depuis imposed_mesh avec le mesher
générique barycenter (des nœuds neufs colocalisés
au centre de gravité de chaque cellule). Mais l’utilisateur reste libre :
nœuds colocalisés, décalés, ou même réutiliser les nœuds contraints eux-mêmes.
Quatre noms de variables
Deux sont requis, deux sont déduits et surchargeables :
| rôle | nom | fourniture |
|---|---|---|
| le modèle contraint | target | requis |
variable imposée (une primale de target) | variable (ex "T") | requis |
duale de la cible (ligne où atterrit la réaction Cᵀ) | lue dans target | déduit |
| primale propre = multiplicateur (inconnue du système) | lambda_<variable> | déduit |
duale propre = ligne de contrainte + slot où l’utilisateur écrit u_d | imposed_<variable> | déduit |
Signature complète :
model.dirichlet(target, variable, imposed_mesh, multiplier_mesh, sense="=")
Cinq arguments, et chacun porte une décision. La cible est le modèle qu’on
contraint : variable doit être une de ses primales, et sa ligne duale s’y lit
— le passage T → q est l’affaire de la physique visée, pas quelque chose à
retaper. Le donner permet aussi de refuser à la construction une variable que
la cible ne déclare pas, au lieu de découvrir une matrice singulière.
Une variable, et une seule. Pour encastrer, on compose :
dirichlet(m, "u_x", …) | dirichlet(m, "u_y", …), les deux partageant leur
maillage multiplicateur — lambda_u_x et lambda_u_y sur un même nœud sont des
DDL distincts. Une contrainte est une famille de relations scalaires avec un
multiplicateur par relation ; on n’empaquette plusieurs composantes que
lorsqu’elles partagent quelque chose de coûteux, ce qu’un Dirichlet ne fait pas.
sense ("=", ">=", "<=") rend la contrainte unilatérale (u ≥ u_d :
une butée) — voir la section « Relations unilatérales » de la page
Contraintes et le solveur solve_unilateral.
Les deux blocs unité
À l’assemblage, Dirichlet contribue une paire de blocs unité par
sous-maillage, chacun marqué non-symétrique (seule l’union C ∪ Cᵀ l’est
— propriété globale du système point-selle ; cf. le drapeau symmetric de
la Matrice) :
- bloc C :
(multiplier_node, imposed_value) × (imposed_node, imposed_variable) = 1 - bloc Cᵀ :
(imposed_node, target_dual) × (multiplier_node, multiplier) = 1
Le bloc C exprime la relation u(n) = u_d ; le bloc Cᵀ réinjecte la
réaction dans l’équation de la physique cible (ligne target_dual).
Valeur imposée et réaction
- La valeur imposée
u_dn’est pas stockée dans le SubModel : l’utilisateur la fournit dans leNodeFieldde chargement, à la position(multiplier_node, imposed_value)— c’est-à-dire au slotimposed_<v>du nœud-multiplicateur. - Le multiplicateur se retrouve dans la solution sous le nom
multiplier(lambda_<v>) au nœud-multiplicateur ; sa valeur est la force de réaction de la contrainte.
Les nœuds-multiplicateurs vivent tant que leur maillage ou le SubModel les référence (refcounts) ; quand les deux disparaissent, ils deviennent collectables. Le SubModel ne décrémente que ce qu’il partage — il n’a rien créé.
Exemple : Poisson 1-D -u'' = 0, u(0)=0, u(1)=1
Solution analytique u(x) = x, multiplicateurs aux bords = flux ±1. On
compose la conduction thermique avec deux contraintes Dirichlet par l’union |
(cf. Modèle physique) :
import pyrucast
# 1) Maillage + FE space
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) Multiplier supports: barycenter co-locates fresh nodes.
imposed_left = pyrucast.mesh.poi1_from_nodes([nodes[0]])
imposed_right = pyrucast.mesh.poi1_from_nodes([nodes[-1]])
mult_mesh_left = pyrucast.mesh.barycenter(imposed_left)
mult_mesh_right = pyrucast.mesh.barycenter(imposed_right)
conduction = pyrucast.model.heat_conduction(fes)
left = pyrucast.model.dirichlet(conduction, "T", imposed_left, mult_mesh_left)
right = pyrucast.model.dirichlet(conduction, "T", imposed_right, mult_mesh_right)
mult_left = mult_mesh_left.node(0, 0, 0)
mult_right = mult_mesh_right.node(0, 0, 0)
# 3) Whole model: conduction + both Dirichlet.
model = conduction | left | right
materials = pyrucast.element_field.material_field(model, [("k", 1.0)])
# 4) Loading: the `constraint_rhs` helper designates each constraint by its
# constrained node and writes u_d at the multiplier node's imposed_T slot.
# Both are merged with `|`.
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 in the middle
assert abs(solution.value(mult_left, "lambda_T") - 1.0) < 1e-10 # flux on the left
La forme Rust équivalente (opérateurs au niveau parent, composés par
union) est dans le chapitre Modèle physique.
Limitations actuelles
imposed_meshetmultiplier_meshsont des maillages POI1 (contrainte par nœud). Les contraintes réparties (sur une arête entière) passeront par un blocCissu d’une intégration, commefluxpour les seconds membres.- Seule la valeur imposée constante par nœud est gérée ; une valeur spatialement variable se fournit nœud par nœud dans le chargement.
Multi-points (MPC)
Une contrainte multi-points (MPC) impose une relation linéaire entre degrés de liberté :
\[ \sum_k a_k \, u(n_k, v_k) = g . \]
C’est une contrainte imposée par multiplicateurs de
Lagrange, exactement comme Dirichlet — dont elle est la
généralisation : Dirichlet est la relation à un seul terme 1·u = u_d
(coefficient 1), une MPC en a autant qu’on veut, avec des coefficients
quelconques. Aucun matériau, aucune loi de comportement ; elle ne crée aucun
nœud et ne mute jamais le Coords.
Implémentation : src/models/mpc.rs ; opérateur ops::model::mpc(…).
Mise en donnée : un maillage par terme
Chaque terme est un tuple (maillage POI1, variable, dual, coefficient).
Tous les terme-maillages et le multiplier_mesh sont appariés
élément-par-élément : la relation r relie la r-ème cellule de chaque
terme-maillage au r-ème nœud multiplicateur. Une périodicité entre deux
surfaces de N nœuds est donc N relations d’un coup, vectorisées sur les
cellules — sans boucle Python.
Contrat d’appariement. L’ordre cohérent des maillages appariés est à la charge de l’utilisateur : la cellule
rde chaque terme-maillage doit désigner des nœuds partenaires (p. ex. le nœud d’entrée et son image périodique). Le modèle vérifie seulement que tout est POI1, partage un mêmeCoords, et a le même nombre de cellules par sous-maillage.
Le dual de chaque terme (la ligne où atterrit la réaction aₖ·λ) se trouve
facilement avec Model.dual_of(variable) — appariement positionnel
primal_vars[i] ↔ dual_vars[i] de la physique qui déclare la variable
("T" → "q", "u_x" → "f_x", …).
Deux noms de variables
L’MPC partage une paire de variables entre toutes ses relations, toutes deux surchargeables :
| rôle | nom | défaut |
|---|---|---|
primale propre = multiplicateur λ (inconnue du système) | multiplier | lambda_mpc |
duale propre = ligne de contrainte + slot où l’utilisateur écrit g | imposed_value | mpc_rhs |
Signature complète :
model.mpc(target, terms, multiplier_mesh, sense="=")
# terms : [(maillage, variable, coefficient), …]
# terms : liste de (mesh, variable, dual, coefficient)
sense ("=", ">=", "<=") rend les relations unilatérales
(Σₖ aₖ·uₖ ≥ g : une liaison à jeu) — voir la section « Relations
unilatérales » de la page Contraintes et le solveur
solve_unilateral.
Les blocs C / Cᵀ
À l’assemblage, l’MPC contribue une paire de blocs par (sous-maillage, terme),
via le même code partagé que Dirichlet mais avec le coefficient aₖ au
lieu de 1 (chaque bloc est marqué non-symétrique ; seule l’union C ∪ Cᵀ
l’est — propriété globale du système point-selle) :
- bloc C :
(multiplier_node, imposed_value) × (nœud_k, variable_k) = aₖ - bloc Cᵀ :
(nœud_k, dual_k) × (multiplier_node, multiplier) = aₖ
Tous les termes d’une relation partagent le même nœud multiplicateur et la
même ligne imposed_value : c’est ce qui les additionne dans une seule
équation Σₖ aₖ uₖ = g.
Second membre et réaction
- Le second membre
gn’est pas stocké dans le SubModel : l’utilisateur l’écrit dans leNodeFieldde chargement, au slotmpc_rhsdu nœud-multiplicateur (défautg = 0— le cas homogène des égalités et périodicités). - Le multiplicateur se retrouve dans la solution sous
lambda_mpcau nœud-multiplicateur ; sa valeur est la force de réaction de la contrainte.
Exemple : relation T(1) − T(0) = 1
Sur la barre 1-D -u'' = 0, un Dirichlet T(0) = 0 et une MPC à deux termes
1·T(1) − 1·T(0) = 1 imposent T(1) = 1, d’où la solution linéaire u(x) = 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]])
mult0 = pyrucast.mesh.barycenter(imposed0)
dirichlet = pyrucast.model.dirichlet(base, "T", imposed0, mult0)
# 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]])
mult_mpc = pyrucast.mesh.barycenter(mesh_last)
mpc = pyrucast.model.mpc(
conduction,
[(mesh_last, "T", 1.0), (mesh_first, "T", -1.0)],
mult_mpc,
)
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` helper designates each relation by a node (the constrained
# node for Dirichlet, the term node for the MPC) and finds the multiplier node
# and component on its own (`imposed_T`, `mpc_rhs`). Both are merged with `|`.
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
L’exemple complet est dans examples/mpc_periodicite.py.
Limitations actuelles
- Les terme-maillages sont POI1 (un nœud par relation) ; l’appariement est positionnel (aucun outil géométrique fourni).
- Les coefficients sont des scalaires par terme (constants sur les cellules du terme-maillage). Les coefficients variant par nœud (poids d’interpolation, bras de levier) sont couverts par une contrainte dédiée, Baignage (embedded).
- Une seule relation reliant un grand nombre de termes (
Σsur 100 nœuds) demanderait autant de terme-maillages ; ce cas passera par une extension « cellule multi-nœuds ». Beaucoup de relations parallèles (le cas courant) sont déjà vectorisées.
Baignage (embedded)
Une contrainte embedded (ou « baignage ») lie le champ de chaque nœud
d’un maillage immergé à l’interpolation d’un maillage hôte au même point de
l’espace. Pour un nœud immergé p logé dans la maille hôte de fonctions de forme
Nᵢ(ξ), et pour chaque composante c :
\[ u_c(p) - \sum_i N_i(\xi_p)\, u_c(\text{hôte}_i) = g_c \qquad (g_c = 0 : \text{liaison rigide}). \]
C’est l’archétype d’une barre baignée dans un volume : les nœuds de la barre
suivent le champ de déplacement volumique, sans que les deux maillages
partagent de nœud. Comme Dirichlet et MPC, c’est une
contrainte par multiplicateurs de Lagrange : ni
matériau, ni loi de comportement ; elle ne mute jamais le Coords.
C’est aussi la réponse au cas laissé ouvert par la MPC : des coefficients qui
varient par nœud (ici les Nᵢ(ξ_p), différents à chaque nœud immergé), qu’une
model.mpc à coefficients scalaires ne sait pas exprimer de façon compacte.
Implémentation : src/models/embedded.rs ; opérateur ops::model::embedded(…).
Localisation à la construction
Les poids de couplage Nᵢ(ξ_p) sont calculés une seule fois, à la
construction, en localisant chaque nœud immergé dans le maillage hôte
(mapping iso-paramétrique inverse : un Newton sur le résidu
x − Σ Nᵢ(ξ)·Xᵢ, avec test d’appartenance au domaine de référence de la maille).
Un nœud immergé qui ne tombe dans aucune maille hôte est une erreur : le
maillage immergé doit être contenu dans l’hôte. Les deux maillages doivent
partager un même Coords (les identifiants de nœuds y sont relatifs).
Les nœuds-multiplicateurs sont mintés en interne (un par nœud immergé,
colocalisé), contrairement à Dirichlet/MPC où l’utilisateur fournit le
multiplier_mesh. On y accède après coup avec Model.multiplier_mesh().
Une relation par (nœud immergé × composante)
Toutes les composantes partagent un nœud-multiplicateur par nœud immergé, chacune portant sa propre paire de variables (toutes surchargeables) :
| rôle | nom | défaut |
|---|---|---|
| primale contrainte (colonne partagée immergé ↔ hôte) | variable | — (fournie) |
| duale cible où atterrit la réaction | target_dual | — (fournie, cf. dual_of) |
primale propre = multiplicateur λ | multiplier | lambda_<variable> |
duale propre = ligne de contrainte + slot de g | imposed_value | imposed_<variable> |
Signature complète :
model.embedded(target, immersed, host, variables, tol=None)
# variables : les primales à lier, p.ex. ["u_x", "u_y", "u_z"]
La cible est le modèle contraint : chaque variable doit être une de ses
primales — refusé sinon, en nommant ce qu’elle déclare — et la ligne duale où
atterrit la réaction s’y lit. On ne redonne donc que les noms de variables,
jamais les couples (primale, duale).
Les blocs C / Cᵀ
À l’assemblage, la contrainte contribue une paire de blocs par composante. Le
nœud immergé porte le coefficient +1, chaque nœud hôte son poids −Nᵢ, si bien
que chaque relation lit u_c(p) − Σᵢ Nᵢ·u_c(hôteᵢ) = g_c :
- bloc C :
(multiplier_node, imposed_value) × (nœud, variable)=+1sur le nœud immergé,−Nᵢsur chaque nœud hôte ; - bloc Cᵀ :
(nœud, target_dual) × (multiplier_node, multiplier), mêmes coefficients (réaction réinjectée dans la physique).
Second membre et réaction
- Le second membre
g(défaut0, la liaison rigide) s’écrit dans leNodeFieldde chargement, au slotimposed_<variable>du nœud-multiplicateur. - Le multiplicateur
lambda_<variable>au nœud-multiplicateur est la force de liaison.
Exemple : barre baignée dans un HEX8
Un cube HEX8 en conduction thermique, ses huit coins fixés à un champ linéaire
T(x) = 1 + 2x + 3y + 4z (que l’interpolation trilinéaire reproduit exactement à
l’intérieur), et un nœud immergé au cœur : sa température résolue égale
l’interpolation de l’hôte.
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)
# Corners fixed to the linear field (Dirichlet).
corner_mesh = pyrucast.mesh.poi1_from_nodes(corner_nodes)
corner_mult = pyrucast.mesh.barycenter(corner_mesh)
dirichlet = pyrucast.model.dirichlet(base, "T", corner_mesh, corner_mult)
# Immersed node, tied to the host.
p = c.add_node([0.3, 0.6, 0.2])
bar = pyrucast.mesh.poi1_from_nodes([p])
embedded = pyrucast.model.embedded(base, bar, host, ["T"])
emb_mult = embedded.multiplier_mesh().node(0, 0, 0)
model = base | dirichlet | embedded
materials = pyrucast.element_field.material_field(model, [("k", 1.0)])
# Loading: the field's value at each corner, g = 0 (tie) at the immersed node.
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
L’exemple complet est dans examples/barre_baignee.py. La variante
vectorielle — une barre suivant les déplacements d’un volume élastique en
u_x/u_y/u_z, le cas qui motive le baignage — est dans
examples/barre_baignee_elastique.py.
Limitations actuelles
- Le maillage immergé est réduit à ses nœuds (support POI1 interne) ; on lie des nœuds à une interpolation, pas des mailles à des mailles (pas de couplage surfacique / cohésif).
- La localisation fait un balayage des mailles hôtes (rejet par boîte englobante) ; pas encore d’index spatial — coûteux pour de très gros hôtes.
- Types hôtes supportés : tous les éléments à cadre de référence (SEG, TRI, QUA,
TET, PENTA, HEX — linéaires et quadratiques). Le
POI1n’a pas d’intérieur et est ignoré comme hôte.
Contact (nœud-surface)
Une contrainte contact empêche les nœuds d’un maillage esclave de
pénétrer une surface maître — le sibling unilatéral du
baignage. Chaque nœud esclave s est apparié à sa facette
maître la plus proche (poids de projection Nᵢ(ξ), normale n, jeu initial
signé g₀), et la non-pénétration linéarisée s’écrit, par nœud esclave :
\[ g_0 + n \cdot u(s) - \sum_i N_i(\xi)\, n \cdot u(\text{maître}_i) \ \geq\ 0. \]
C’est une relation unilatérale (≥, cf. la section Relations
unilatérales) à coefficients variant par nœud (comme le
baignage) et couplant toutes les composantes du déplacement via la
normale. Le modèle se résout avec
solve_unilateral ; le multiplicateur λ ≤ 0
porte la réaction de contact (λ = 0 quand la paire est décollée).
Implémentation : src/models/contact.rs ; opérateur ops::model::contact(…).
Appariement à la construction (petits glissements)
L’appariement est calculé une seule fois, à la construction, par
projection au point le plus proche de chaque nœud esclave sur la surface
maître (ops::geom::project_points, cf.
Opérateurs géométriques) : facette, ξ (clampé
au domaine de référence — un nœud face à un bord se projette sur le bord),
poids Nᵢ(ξ), normale et jeu signé. Appariement et normale sont ensuite
figés : c’est le contact linéarisé (petits déplacements, petits
glissements, sans frottement). Les deux maillages doivent partager un même
Coords.
Orientation. La surface maître doit être orientée de façon cohérente,
normale pointant vers le corps esclave : en 2D la normale d’un SEG2 est
la tangente tournée de −90° (n = (t_y, −t_x)), en 3D celle d’un TRI3/QUA4
suit la règle de la main droite sur l’ordre des nœuds. Le jeu g₀ est alors
positif quand c’est décollé, négatif quand ça pénètre.
Les nœuds-multiplicateurs sont mintés en interne (un par nœud esclave,
colocalisé), accessibles après coup avec Model.multiplier_mesh().
Une relation par nœud esclave
Toutes les relations partagent la paire de variables du sous-modèle (surchargeables) :
| rôle | nom | défaut |
|---|---|---|
primale propre = réaction de contact λ | multiplier | lambda_contact |
duale propre = ligne de contrainte + slot de −g₀ | imposed_value | contact_gap |
Signature complète :
model.contact(target, slave, master, variables)
# components : une paire (variable, target_dual) PAR dimension d'espace,
# dans l'ordre ambiant, p.ex. [("u_x","f_x"), ("u_y","f_y")]
Contrairement au baignage (une relation par composante), le contact écrit
une seule relation scalaire par nœud esclave : la normale couple les
composantes entre elles (coefficients +n_c sur l’esclave, −Nᵢ·n_c sur
chaque nœud maître). components doit donc en donner exactement une par
dimension.
Second membre : le helper contact_gaps
Le second membre de chaque relation est −g₀ — une donnée géométrique
que le sous-modèle connaît déjà. Le helper la transforme en champ de
chargement, à fusionner avec | :
rhs = traction | model.contact_gaps()
L’omettre revient à traiter toutes les paires comme initialement en
contact (g₀ = 0).
Exemple : patch test à deux blocs
Deux blocs élastiques empilés (jeu initial g₀), u_x bloqué partout
(colonne uniaxiale), pression S sur le bloc du haut : le contact se ferme et
transmet exactement σ_yy = −S ; les réactions −λᵢ sont les forces nodales
cohérentes de la pression (Σ(−λᵢ) = S). En soulevant le bloc du haut, toutes
les paires se relâchent (λ = 0 exactement).
# Master: upper edge of the lower block, walked in −x (normal +y, towards the slave).
master = pyrucast.Mesh(c, "SEG2")
for i in reversed(range(N)):
master.unit().add_cell([bottom[idx(i + 1, N)], bottom[idx(i, N)]])
# Slave: nodes of the upper block's lower edge.
slave = pyrucast.mesh.poi1_from_nodes([top[idx(i, 0)] for i in range(N + 1)])
contact = pyrucast.model.contact(elasticite, slave, master, ["u_x", "u_y"])
# The upper edge's pressure is a term of the model, like the 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)]
)
rhs = pyrucast.node_field.external_forces(model, materials) | model.contact_gaps()
solution = pyrucast.solver.solve_unilateral(
pyrucast.matrix.stiffness(model, materials), model, rhs
)
Le déroulé complet (2D et 3D) est dans tests/contact.rs et
tests/python/test_contact.py.
Périmètre v1 et suites
- Petits glissements : appariement et normale figés à la construction ; un grand glissement demandera un ré-appariement en boucle (orchestré côté Python, comme le Newton de la plasticité).
- Sans frottement : seul le jeu normal est contraint, le glissement tangentiel est libre.
- Nœud-surface simple passe : pas de traitement maître/esclave symétrique, pas de mortar.
Éléments finis supportés
Cette section est le catalogue de référence des éléments finis de pyrucast : une fiche par type, avec le repère de référence, les fonctions de forme \( N_i(\xi) \) exactes, leurs dérivées de référence \( \partial N_i/\partial\xi_k \), et la règle de quadrature associée.
La machinerie commune à tous les éléments (transformation isoparamétrique,
Jacobien, gradient physique \( \partial N_i/\partial x_a \), passage à la
matrice élémentaire) est décrite une fois pour toutes au chapitre
Espace éléments finis ; les
fiches ci-dessous ne répètent que ce qui est propre à chaque élément. Le code
source de chaque élément tient dans un fichier,
atoms/element_kind/<nom>.rs : fonctions de forme, points de Gauss, facettes,
domaine de référence et codes d’échange y sont réunis. Les conventions de
repère et de numérotation locale sont documentées sur la variante
correspondante d’atoms/element_type.rs.
Toutes les fiches suivent le même plan standard : introduction (nombre de nœuds, famille, dimensions), Repère de référence (domaine + numérotation locale), Fonctions de forme, Dérivées de référence, Quadrature (défaut) (points, poids, exactitude), puis Notes (dimensions valides, propriétés, renvois vers les variantes).
Rappel : de la fonction de forme à la matrice
Sur chaque élément, un champ est interpolé par ses valeurs nodales, \( u(\xi) = \sum_i N_i(\xi)\,u_i \), et la géométrie de la même façon (hypothèse isoparamétrique), \( \mathbf{x}(\xi) = \sum_i N_i(\xi)\,\mathbf{x}_i \). Toute matrice élémentaire est une intégrale sur l’élément physique 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(\chi(\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^{-\top}\nabla_\xi N_i \). Les seuls ingrédients qui changent d’un élément à l’autre sont donc : les \( N_i \), les \( \partial N_i/\partial\xi_k \), et le couple \( (\xi_g, w_g) \) — exactement le contenu de chaque fiche.
Catalogue
Deux familles d’interpolation Lagrange sont disponibles : Lagrange-1
(linéaire, un nœud par sommet) et Lagrange-2 (quadratique, sommets + nœuds
de milieu d’arête). POI1 (nœud seul) n’a pas de repère de référence : ce n’est
pas un élément fini.
Lagrange-1 (linéaire)
| Élément | Nœuds | Dim. topo. | Domaine de référence | Quadrature (\( n_g \)) |
|---|---|---|---|---|
| SEG2 | 2 | 1 | \( \xi\in[-1,1] \) | Gauss 2 pts |
| TRI3 | 3 | 2 | simplexe \( \xi+\eta\le 1 \) | Hammer 3 pts |
| QUA4 | 4 | 2 | \( [-1,1]^2 \) | 2×2 Gauss (4) |
| TET4 | 4 | 3 | simplexe \( \xi+\eta+\zeta\le 1 \) | Hammer 4 pts |
| PYRA5 | 5 | 3 | pyramide (section carrée décroissante) | Gauss×Jacobi conique (8) |
| PENTA6 | 6 | 3 | prisme (TRI3 × \( \zeta \)) | TRI×Gauss (6) |
| HEX8 | 8 | 3 | \( [-1,1]^3 \) | 2×2×2 Gauss (8) |
Lagrange-2 (quadratique)
| Élément | Nœuds | Dim. topo. | Parent | Type | Quadrature (\( n_g \)) |
|---|---|---|---|---|---|
| SEG3 | 3 | 1 | SEG2 | complet | Gauss 3 pts |
| TRI6 | 6 | 2 | TRI3 | complet | Dunavant deg. 4 (6) |
| QUA8 | 8 | 2 | QUA4 | sérendipité | 3×3 Gauss (9) |
| QUA9 | 9 | 2 | QUA4 | complet (Q2) | 3×3 Gauss (9) |
| TET10 | 10 | 3 | TET4 | complet | Keast deg. 4 (11) |
| PENTA15 | 15 | 3 | PENTA6 | sérendipité | TRI6×Gauss (18) |
| HEX20 | 20 | 3 | HEX8 | sérendipité | 3×3×3 Gauss (27) |
| HEX27 | 27 | 3 | HEX8 | complet (Q2) | 3×3×3 Gauss (27) |
Les éléments sérendipité (QUA8, HEX20, PENTA15) ne portent que des nœuds d’arête ; les complets (SEG3, TRI6, TET10, QUA9, HEX27) portent en plus les nœuds de face et/ou de volume nécessaires au produit tensoriel \( Q2 \) complet.
Catalogue de quadrature
Le catalogue ci-dessus ne montre, par élément, que la règle par défaut
(GAUSS). Une deuxième règle existe — REDUCED (intégration réduite : un seul
point au centroïde, poids = mesure du domaine de référence, exacte pour les
constantes seulement ; utilisée par exemple pour désamorcer le verrouillage en
cisaillement de la poutre de Timoshenko). Le
tableau croisé suivant donne, pour chaque couple (élément, règle), le nombre de
points d’intégration \( n_g \) si le couple est supporté :
| Élément | GAUSS | REDUCED |
|---|---|---|
| SEG2 | ✓ (2) | ✓ (1) |
| TRI3 | ✓ (3) | ✓ (1) |
| QUA4 | ✓ (4) | ✓ (1) |
| TET4 | ✓ (4) | ✓ (1) |
| PYRA5 | ✓ (8) | ✓ (1) |
| PENTA6 | ✓ (6) | ✓ (1) |
| HEX8 | ✓ (8) | ✓ (1) |
| SEG3 | ✓ (3) | ✓ (1) |
| TRI6 | ✓ (6) | ✓ (1) |
| QUA8 | ✓ (9) | ✓ (1) |
| QUA9 | ✓ (9) | ✓ (1) |
| TET10 | ✓ (11) | ✓ (1) |
| PENTA15 | ✓ (18) | ✓ (1) |
| HEX20 | ✓ (27) | ✓ (1) |
| HEX27 | ✓ (27) | ✓ (1) |
| POI1 | — | — |
POI1 n’a pas de repère de référence (ce n’est pas un élément fini) : les deux
règles y sont rejetées (QuadratureRule::is_compatible_with renvoie false,
points/point_count renvoient une erreur). Pour tout autre ElementType,
les deux règles sont actuellement définies — le tableau est donc plein sauf
sur cette ligne. Il est conservé tel quel pour documenter la compatibilité au
fur et à mesure que de nouvelles règles (ordres supérieurs, quadratures
spécialisées) seront ajoutées : celles-ci pourront être incompatibles avec
certains éléments (p. ex. une règle calibrée pour un degré d’exactitude
indisponible sur un élément sérendipité), et ce tableau sera le seul endroit à
mettre à jour.
Propriétés communes (vérifiées par les tests)
Toutes les interpolations Lagrange satisfont, à tout point de référence :
- Kronecker : \( N_i(\xi_j) = \delta_{ij} \) aux nœuds — l’interpolation passe par les valeurs nodales ;
- partition de l’unité : \( \sum_i N_i(\xi) = 1 \), d’où la reproduction exacte des champs constants ;
- dérivées à somme nulle : \( \sum_i \partial N_i/\partial\xi_k = 0 \) (partition de l’unité dérivée) ;
- pour les éléments quadratiques, les dérivées analytiques sont recoupées par différences finies centrées dans les tests unitaires.
La règle de quadrature par défaut de chaque élément est calibrée pour intégrer exactement sa matrice de masse sur une géométrie droite ; la somme des poids vaut la mesure du domaine de référence.
SEG2 — segment linéaire
Segment à 2 nœuds, interpolation Lagrange-1. Élément 1-D de base
(barres, bords, poutres). Peut être plongé dans une Coords 2-D ou 3-D
(mesure de longueur via le Jacobien manifold).
Repère 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). \]
Dérivées de référence
Constantes sur l’élément :
\[ \frac{\partial N_0}{\partial \xi} = -\tfrac{1}{2}, \qquad \frac{\partial N_1}{\partial \xi} = +\tfrac{1}{2}. \]
Quadrature (défaut)
Gauss-Legendre à 2 points, exacte pour les polynômes de degré \( \le 3 \) :
\[ \xi_g = \pm\frac{1}{\sqrt 3}, \qquad w_g = 1, \qquad \sum_g w_g = 2. \]
Notes
- Dimensions valides : \( d_r = 1 \), \( d_s \in {1, 2, 3} \) (segment plongé dans une droite, un plan ou l’espace).
- Sur une géométrie droite, \( |J| = L/2 \) (\( L \) = longueur physique), et la matrice de masse Lagrange-1 \( \int N_i N_j\,ds = \tfrac{L}{6}\begin{bmatrix}2&1\\1&2\end{bmatrix} \) est intégrée exactement par la règle à 2 points.
- Version quadratique : SEG3.
TRI3 — triangle linéaire
Triangle à 3 nœuds, interpolation Lagrange-1. L’élément 2-D le plus
simple ; interpolation affine, donc gradient et déformation constants par
élément. Peut être plongé dans une Coords 3-D (surface).
Repère de référence
Simplexe unité \( \xi, \eta \in [0, 1] \), \( \xi + \eta \le 1 \), parcouru CCW.
| Nœud | \( (\xi, \eta) \) |
|---|---|
| 0 | \( (0, 0) \) |
| 1 | \( (1, 0) \) |
| 2 | \( (0, 1) \) |
Fonctions de forme
Ce sont les coordonnées barycentriques \( L_0 = 1-\xi-\eta \), \( L_1 = \xi \), \( L_2 = \eta \) :
\[ N_0 = 1 - \xi - \eta, \qquad N_1 = \xi, \qquad N_2 = \eta. \]
Dérivées de référence
Constantes sur l’élément :
\[ \nabla_\xi N_0 = (-1, -1), \qquad \nabla_\xi N_1 = (1, 0), \qquad \nabla_\xi N_2 = (0, 1). \]
Quadrature (défaut)
Règle de Hammer mid-edge à 3 points, exacte au degré \( \le 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. \]
Notes
- Dimensions valides : \( d_r = 2 \), \( d_s \in {2, 3} \) (triangle plan
ou plongé dans l’espace — c’est ce que produit
triangulate_surfacesur un contour 3-D). - Gradient constant : un seul point de Gauss suffirait pour un champ affine, mais la règle à 3 points intègre exactement la masse (\( \int N_i N_j \), de degré 2).
- Version quadratique : TRI6.
QUA4 — quadrangle bilinéaire
Quadrangle à 4 nœuds, interpolation Lagrange-1 (produit tensoriel
\( Q1 \)). Interpolation bilinéaire : le gradient varie linéairement dans
l’élément. Peut être plongé dans une Coords 3-D (surface).
Repère de référence
\( \xi, \eta \in [-1, +1] \), sommets parcourus CCW.
| 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 de référence \( (\xi_i, \eta_i) \) :
\[ N_i(\xi, \eta) = \tfrac{1}{4}\,(1 + \xi_i\,\xi)\,(1 + \eta_i\,\eta). \]
Explicitement :
\[ \begin{aligned} N_0 &= \tfrac14(1-\xi)(1-\eta), & N_1 &= \tfrac14(1+\xi)(1-\eta), \\ N_2 &= \tfrac14(1+\xi)(1+\eta), & N_3 &= \tfrac14(1-\xi)(1+\eta). \end{aligned} \]
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). \]
Quadrature (défaut)
Produit tensoriel 2×2 de Gauss-Legendre, exacte au degré \( \le 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. \]
Notes
- Dimensions valides : \( d_r = 2 \), \( d_s \in {2, 3} \).
- Le terme bilinéaire \( \xi\eta \) enrichit l’interpolation par rapport à un TRI3 : le QUA4 reproduit exactement les champs bilinéaires.
- Version quadratique sérendipité : QUA8 ; version complète : QUA9.
TET4 — tétraèdre linéaire
Tétraèdre à 4 nœuds, interpolation Lagrange-1. Élément 3-D simplicial ; interpolation affine, gradient et déformation constants par élément (analogue 3-D du TRI3). C’est l’élément produit par le mailleur de volume.
Repère de référence
Simplexe unité \( \xi, \eta, \zeta \in [0, 1] \), \( \xi + \eta + \zeta \le 1 \). Face 0-1-2 orientée CCW 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
Coordonnées barycentriques \( L_0 = 1-\xi-\eta-\zeta \), \( L_1 = \xi \), \( L_2 = \eta \), \( L_3 = \zeta \) :
\[ 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 sur l’élément :
\[ \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). \]
Quadrature (défaut)
Règle de Hammer à 4 points (exacte au degré \( \le 2 \)) : avec \( \alpha = \tfrac{5 - \sqrt5}{20} \) et \( \beta = \tfrac{5 + 3\sqrt5}{20} \), les points sont les permutations \( (\beta, \alpha, \alpha) \) et \( (\alpha, \alpha, \alpha) \),
\[ w_g = \tfrac{1}{24}, \qquad \sum_g w_g = \tfrac{1}{6}. \]
Notes
- Dimensions valides : \( d_r = d_s = 3 \).
- Élément à déformation constante (CST 3-D) : convergence lente en flexion, mais robuste et facile à mailler (Delaunay / Bowyer–Watson).
- Version quadratique : TET10.
PYRA5 — pyramide linéaire
Pyramide à 5 nœuds — base carrée et sommet — interpolation Lagrange-1.
C’est l’élément de raccord entre hexaèdres et 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 peut donc être refermée sur un cœur
tétraédrique sans nœud en T. Sans lui, il n’existe pas de maillage volumique
conforme mêlant les deux.
Repère 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’à se réduire au sommet. Base parcourue CCW vue depuis le sommet, puis 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
La pyramide est le seul élément courant dont les fonctions de forme ne sont pas polynomiales, et ce n’est pas un choix : sa base carrée doit s’effondrer sur un point unique au sommet, et aucun polynôme ne fait cela tout en restant bilinéaire sur la base.
En notant \( m = 1 - \zeta \) la demi-largeur de la section, ce sont les fonctions bilinéaires dans les coordonnées mises à l’échelle \( \xi/m,\ \eta/m \), pondéré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 \) sur la base. 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 croisé \( \xi\eta/m \) est la partie rationnelle — et la raison pour laquelle la pyramide réclame une quadrature à elle. Il reste borné sur l’élément de référence (\( |\xi|, |\eta| \le m \), donc il vaut au plus \( m/4 \)) mais il est bel et bien singulier au sommet, où la limite \( N_4 = 1 \) est prise directement.
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) \). Les trois sommes sur les cinq nœuds s’annulent, comme il se doit — les identités \( \sum_i \xi_i = \sum_i \eta_i = \sum_i \xi_i\eta_i = 0 \) sur la base carrée y suffisent.
Quadrature (défaut)
Une pyramide n’est le produit d’aucune paire de simplexes : elle reçoit donc une règle conique, produit d’une règle de Gauss–Legendre 2 × 2 sur la section carrée par une règle de Gauss–Jacobi à 2 points en \( \zeta \), soit 8 points.
C’est le poids de Jacobi qui fait l’affaire. En écrivant un point sous la forme \( \xi = a(1-\zeta) \), \( \eta = b(1-\zeta) \) avec \( a, b \in [-1, 1] \), le changement de variables fait apparaître
\[ \mathrm{d}\xi\,\mathrm{d}\eta = (1-\zeta)^2\,\mathrm{d}a\,\mathrm{d}b, \]
soit exactement le rétrécissement de la section vers le sommet. Intégrer la direction \( \zeta \) contre ce \( (1-\zeta)^2 \) est une règle de Gauss–Jacobi de paramètre \( \alpha = 2 \), dont les deux nœuds sont les racines de \( z^2 - \tfrac23 z + \tfrac1{15} \) :
\[ \zeta_g = \frac13 \mp \frac{\sqrt{10}}{15} \quad\Longrightarrow\quad \zeta_g \simeq 0{,}12251 \ \text{et}\ 0{,}54415, \]
et les poids se déduisent des deux premiers moments de \( (1-z)^2 \) sur \( [0,1] \) (\( \sum w = 1/3 \), \( \sum w z = 1/12 \)) :
\[ w_g \simeq 0{,}23255 \ \text{et}\ 0{,}10079. \]
La somme des poids vaut alors \( 2 \times 2 \times \tfrac13 = \tfrac43 \), le volume de la pyramide de référence.
Notes
- Dimensions valides : \( d_r = d_s = 3 \).
- Conformité. Sur \( \zeta = 0 \) les fonctions se réduisent exactement
à celles d’un
QUA4, et le long d’une arête base → sommet elles sont linéaires. C’est ce qui garantit la continuité avec unHEX8par la face carrée et avec unTET4par une face triangulaire. - La partie rationnelle rend l’intégration inexacte pour un élément quelconque,
contrairement aux autres types linéaires ; la règle à 8 points est le choix
usuel. Elle reste exacte sur le volume d’une pyramide droite ou oblique, ce
que vérifie le test
pyra5_jacobian_volume. - Lu et écrit par
read_gmsh(type 7) et par l’export VTK (VTK_PYRAMID, 14). - Pas de version quadratique (
PYRA13/PYRA14) pour l’instant.
PENTA6 — prisme (pentaèdre) linéaire
Prisme à 6 nœuds, interpolation Lagrange-1. C’est l’extrusion d’un
TRI3 le long de \( \zeta \) : produit d’un triangle (coordonnées
barycentriques) par un segment linéaire. Produit par les opérateurs extrude
et revolve (TRI3 → PENTA6), ainsi que par sweep_solid.
Repère de référence
Triangle \( \xi, \eta \in [0, 1] \), \( \xi + \eta \le 1 \), extrudé sur \( \zeta \in [0, 1] \). Triangle inférieur (\( \zeta = 0 \)) puis triangle supérieur (\( \zeta = 1 \)), chacun CCW.
| 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
Avec les barycentriques du triangle \( L_1 = 1-\xi-\eta \), \( L_2 = \xi \), \( L_3 = \eta \) et le facteur linéaire en \( \zeta \) :
\[ N_j = L_j\,(1 - \zeta) \quad (j = 0, 1, 2), \qquad N_{j+3} = L_{j+1}\,\zeta \quad (j = 0, 1, 2). \]
(les nœuds 0..2 portent \( L_1, L_2, L_3 \) à \( \zeta=0 \) ; les nœuds 3..5, les mêmes à \( \zeta=1 \)).
Dérivées de référence
Par exemple, pour le nœud 0 (\( L_1(1-\zeta) \)) :
\[ \nabla_\xi N_0 = \big(-(1-\zeta),\ -(1-\zeta),\ -L_1\big), \]
les autres suivant le même schéma (dérivées de \( L_j \) constantes, \( \partial_\zeta \) porté par le facteur \( \zeta \)).
Quadrature (défaut)
Produit tensoriel de la règle TRI3 (3 points, \( w = 1/6 \)) par la règle de Gauss à 2 points sur \( \zeta \in [0, 1] \) (\( \zeta_g = \tfrac12 \pm \tfrac{1}{2\sqrt3} \), \( w = \tfrac12 \)), soit 6 points :
\[ w_g = \tfrac16\cdot\tfrac12 = \tfrac{1}{12}, \qquad \sum_g w_g = \tfrac12. \]
Notes
- Dimensions valides : \( d_r = d_s = 3 \).
- Exact au degré \( \le 2 \) dans le plan du triangle et \( \le 3 \) selon \( \zeta \).
- Utile pour mailler par couches (extrusion d’un maillage surfacique TRI3).
- Version quadratique sérendipité : PENTA15.
HEX8 — hexaèdre trilinéaire
Hexaèdre à 8 nœuds, interpolation Lagrange-1 (produit tensoriel
\( Q1 \)). Interpolation trilinéaire ; l’élément volumique de référence
pour les maillages structurés. Produit par extrude et revolve (QUA4 → HEX8).
Repère de référence
\( \xi, \eta, \zeta \in [-1, +1] \). Face inférieure CCW (nœuds 0..3) puis face supérieure CCW (nœuds 4..7).
| 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 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). \]
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 par permutation circulaire pour \( \partial_\eta \) et \( \partial_\zeta \).
Quadrature (défaut)
Produit tensoriel 2×2×2 de Gauss-Legendre, exacte au degré \( \le 3 \) par direction :
\[ \xi_g = \left(\pm\tfrac{1}{\sqrt 3}\right)^3, \qquad w_g = 1, \qquad \sum_g w_g = 8. \]
Notes
- Dimensions valides : \( d_r = d_s = 3 \).
- Reproduit exactement les champs trilinéaires ; bien meilleur en flexion qu’un TET4, au prix d’un maillage structuré.
- Versions quadratiques : sérendipité HEX20, complète HEX27.
SEG3 — segment quadratique
Segment à 3 nœuds, interpolation Lagrange-2 complète. Parent linéaire : SEG2. Un nœud de milieu porte la courbure de l’interpolation.
Repère de référence
\( \xi \in [-1, +1] \), nœud médian en \( \xi = 0 \).
| Nœud | \( \xi \) | rôle |
|---|---|---|
| 0 | \( -1 \) | sommet |
| 1 | \( +1 \) | sommet |
| 2 | \( 0 \) | milieu \( (0,1) \) |
Fonctions de forme
\[ N_0 = \tfrac12\,\xi(\xi - 1), \qquad N_1 = \tfrac12\,\xi(\xi + 1), \qquad N_2 = 1 - \xi^2. \]
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. \]
Quadrature (défaut)
Gauss-Legendre à 3 points, exacte au degré \( \le 5 \) :
\[ \xi_g = \left(-\sqrt{\tfrac35},\ 0,\ +\sqrt{\tfrac35}\right), \qquad w_g = \left(\tfrac59,\ \tfrac89,\ \tfrac59\right), \qquad \sum_g w_g = 2. \]
Notes
- Dimensions valides : \( d_r = 1 \), \( d_s \in {1, 2, 3} \) (arête courbe plongée dans le plan ou l’espace).
- L’interpolation quadratique suit une géométrie courbe exactement (arête parabolique).
TRI6 — triangle quadratique
Triangle à 6 nœuds, interpolation Lagrange-2 complète. Parent : TRI3, plus 3 nœuds de milieu d’arête. Interpolation quadratique complète \( P2 \) : déformation linéaire par élément.
Repère de référence
Simplexe unité, 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 \( (0,1) \) |
| 4 | \( (\tfrac12, \tfrac12) \) | milieu \( (1,2) \) |
| 5 | \( (0, \tfrac12) \) | milieu \( (2,0) \) |
Fonctions de forme
Sommets \( L_i(2L_i - 1) \), milieux \( 4 L_a L_b \) :
\[ \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} \]
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) \), les sommets donnent \( \nabla_\xi N_i = (4L_i - 1)\,\nabla_\xi L_i \) et les 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). \]
Quadrature (défaut)
Règle symétrique de Dunavant degré 4 à 6 points (deux orbites à 3 points), exacte au degré \( \le 4 \) — vérifiée par intégration de monômes dans les tests. \( \sum_g w_g = \tfrac12 \).
Notes
- Dimensions valides : \( d_r = 2 \), \( d_s \in {2, 3} \).
- Déformation linéaire : bien plus précis qu’un TRI3 en flexion, et suit des bords courbes (arêtes paraboliques).
QUA8 — quadrangle sérendipité
Quadrangle à 8 nœuds, interpolation Lagrange-2 sérendipité (arêtes seulement, pas de nœud central). Parent : QUA4, plus 4 nœuds de milieu d’arête. Variante complète (avec nœud central) : QUA9.
Repère de référence
\( \xi, \eta \in [-1, +1] \). Sommets 0..3 (comme QUA4), milieux 4..7.
| Nœud | \( (\xi, \eta) \) | rôle |
|---|---|---|
| 0..3 | \( (\pm1, \pm1) \) | sommets (CCW) |
| 4 | \( (0, -1) \) | milieu \( (0,1) \) |
| 5 | \( (+1, 0) \) | milieu \( (1,2) \) |
| 6 | \( (0, +1) \) | milieu \( (2,3) \) |
| 7 | \( (-1, 0) \) | milieu \( (3,0) \) |
Fonctions de forme
Sommets (\( (\xi_i, \eta_i) \in {-1,+1}^2 \)) :
\[ N_i = \tfrac14\,(1 + \xi_i\xi)(1 + \eta_i\eta)\,(\xi_i\xi + \eta_i\eta - 1). \]
Milieux d’arête :
\[ \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} \]
Dérivées de référence
Obtenues par dérivation directe des expressions ci-dessus (formes analytiques recoupées par différences finies dans les tests). Par exemple pour le milieu 4 : \( \partial_\xi N_4 = -\xi(1-\eta) \), \( \partial_\eta N_4 = -\tfrac12(1-\xi^2) \).
Quadrature (défaut)
Produit tensoriel 3×3 de Gauss-Legendre (9 points), exacte au degré \( \le 5 \) par direction. \( \sum_g w_g = 4 \).
Notes
- Dimensions valides : \( d_r = 2 \), \( d_s \in {2, 3} \).
- Sérendipité : 8 nœuds au lieu de 9 pour QUA9 — une inconnue de moins par élément, mais l’espace polynomial n’est pas le \( Q2 \) complet (le monôme \( \xi^2\eta^2 \) manque).
QUA9 — quadrangle biquadratique
Quadrangle à 9 nœuds, interpolation Lagrange-2 complète (\( Q2 \) tensoriel). Parent : QUA4, plus 4 milieux d’arête et un nœud central. Contrairement à la sérendipité QUA8, il porte le monôme \( \xi^2\eta^2 \).
Repère de référence
\( \xi, \eta \in [-1, +1] \).
| Nœud | \( (\xi, \eta) \) | rôle |
|---|---|---|
| 0..3 | \( (\pm1, \pm1) \) | sommets (CCW) |
| 4 | \( (0, -1) \) | milieu \( (0,1) \) |
| 5 | \( (+1, 0) \) | milieu \( (1,2) \) |
| 6 | \( (0, +1) \) | milieu \( (2,3) \) |
| 7 | \( (-1, 0) \) | milieu \( (3,0) \) |
| 8 | \( (0, 0) \) | centre |
Fonctions de forme
Produit tensoriel des fonctions de Lagrange quadratiques 1-D sur \( {-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ù \( (\ell_a, \ell_b) \) sélectionne la position (\( -, 0, + \)) du nœud dans chaque direction. Ainsi le nœud central est \( 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 \).
Quadrature (défaut)
Produit tensoriel 3×3 de Gauss-Legendre (9 points), exacte au degré \( \le 5 \) par direction. \( \sum_g w_g = 4 \).
Notes
- Dimensions valides : \( d_r = 2 \), \( d_s \in {2, 3} \).
- Espace polynomial \( Q2 \) complet : reproduit exactement tout produit de polynômes de degré \( \le 2 \) par direction.
TET10 — tétraèdre quadratique
Tétraèdre à 10 nœuds, interpolation Lagrange-2 complète. Parent : TET4, plus 6 nœuds de milieu d’arête. Déformation linéaire par élément — l’élément volumique quadratique le plus courant.
Repère de référence
Simplexe unité, barycentriques \( L_0 = 1-\xi-\eta-\zeta \), \( L_1 = \xi \), \( L_2 = \eta \), \( L_3 = \zeta \). Sommets 0..3 (comme TET4), milieux 4..9 sur les arêtes \( (0,1), (1,2), (2,0), (0,3), (1,3), (2,3) \).
| 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
Sommets \( L_i(2L_i - 1) \), milieux \( 4 L_a L_b \) :
\[ \begin{aligned} N_0 &= L_0(2L_0-1), \ \dots,\ N_3 = L_3(2L_3-1), \\ 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} \]
Dérivées de référence
Avec les gradients barycentriques \( \nabla_\xi L_0 = (-1,-1,-1) \), \( \nabla_\xi L_1 = (1,0,0) \), etc. : sommets \( \nabla_\xi N_i = (4L_i - 1)\,\nabla_\xi L_i \), milieux \( \nabla_\xi N = 4(L_b\,\nabla_\xi L_a + L_a\,\nabla_\xi L_b) \).
Quadrature (défaut)
Règle de Keast degré 4 à 11 points (un point au centroïde à poids négatif, une orbite à 4 et une orbite à 6 points), exacte au degré \( \le 4 \) — vérifiée par intégration de monômes. \( \sum_g w_g = \tfrac16 \).
Notes
- Dimensions valides : \( d_r = d_s = 3 \).
- Suit des faces courbes (arêtes paraboliques) ; excellent compromis précision / génération de maillage pour la mécanique 3-D.
PENTA15 — prisme quadratique sérendipité
Prisme à 15 nœuds, interpolation Lagrange-2 sérendipité. Parent : PENTA6, plus 9 nœuds de milieu d’arête (aucun nœud de face ni de volume). Interpolation quadratique dans le triangle et selon \( \zeta \).
Repère de référence
Triangle \( (L_1, L_2, L_3) \) extrudé sur \( \zeta \in [0, 1] \) (noté \( t \) ci-dessous). Sommets 0..5 (comme PENTA6), puis milieux : bas 6..8 (\( (0,1),(1,2),(2,0) \)), haut 9..11 (\( (3,4),(4,5),(5,3) \)), verticaux 12..14 (\( (0,3),(1,4),(2,5) \), à \( \zeta = \tfrac12 \)).
Fonctions de forme
Avec \( L_1 = 1-\xi-\eta \), \( L_2 = \xi \), \( L_3 = \eta \) et \( t = \zeta \) :
Sommets (bas \( \zeta=0 \) / haut \( \zeta=1 \)) — profil quadratique dans le triangle et correction sérendipité en \( t \) :
\[ \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} \]
Milieux d’arête du triangle (bas puis haut) :
\[ N = 4 L_a L_b\,(1-t) \quad(\text{bas}), \qquad N = 4 L_a L_b\,t \quad(\text{haut}). \]
Milieux verticaux (sur les sommets du triangle, \( \zeta = \tfrac12 \)) :
\[ N = 4 L_i\,t(1 - t). \]
Dérivées de référence
Dérivation directe des expressions ci-dessus (gradients de \( L_i \) constants, dérivée en \( t \) portée par les facteurs \( (1-t) \), \( t \), \( t(1-t) \)) ; formes analytiques recoupées par différences finies.
Quadrature (défaut)
Produit tensoriel de la règle TRI6 (6 points) par Gauss à 3 points sur \( \zeta \in [0, 1] \), soit 18 points. \( \sum_g w_g = \tfrac12 \).
Notes
- Dimensions valides : \( d_r = d_s = 3 \).
- Version complète (avec nœud central) : non fournie — le prisme sérendipité suffit pour l’extrusion de maillages quadratiques.
HEX20 — hexaèdre sérendipité
Hexaèdre à 20 nœuds, interpolation Lagrange-2 sérendipité (arêtes seulement, ni face ni centre). Parent : HEX8, plus 12 nœuds de milieu d’arête. Variante complète : HEX27.
Repère de référence
\( \xi, \eta, \zeta \in [-1, +1] \). Sommets 0..7 (comme HEX8) ; milieux 8..19 : bas \( (0,1),(1,2),(2,3),(3,0) \), haut \( (4,5),(5,6),(6,7),(7,4) \), verticaux \( (0,4),(1,5),(2,6),(3,7) \). Chaque nœud de milieu a exactement une coordonnée de référence nulle.
Fonctions de forme
Pour un nœud de coordonnées de référence \( (p, q, r) \) :
Sommets (\( p, q, r = \pm1 \)) — noter le facteur \( -2 \) :
\[ N_i = \tfrac18\,(1 + p\xi)(1 + q\eta)(1 + r\zeta)\,(p\xi + q\eta + r\zeta - 2). \]
Milieux d’arête, selon la direction de l’arête (celle où la coordonnée est 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} \]
Dérivées de référence
Dérivation directe des expressions ci-dessus (recoupée par différences finies dans les tests).
Quadrature (défaut)
Produit tensoriel 3×3×3 de Gauss-Legendre (27 points), exacte au degré \( \le 5 \) par direction. \( \sum_g w_g = 8 \).
Notes
- Dimensions valides : \( d_r = d_s = 3 \).
- 20 nœuds au lieu de 27 : moins d’inconnues que HEX27, sans les monômes d’ordre le plus élevé (\( \xi^2\eta^2\zeta^2 \) etc.). Bon compromis précision/coût, très utilisé en mécanique 3-D.
HEX27 — hexaèdre tri-quadratique
Hexaèdre à 27 nœuds, interpolation Lagrange-2 complète (\( Q2 \) tensoriel). Parent : HEX8, avec 12 milieux d’arête (comme HEX20), 6 centres de face et 1 centre de volume.
Repère de référence
\( \xi, \eta, \zeta \in [-1, +1] \). Sommets 0..7, milieux d’arête 8..19 (ordre HEX20), centres de face 20..25 (faces \( x^-, x^+, y^-, y^+, z^-, z^+ \)), centre de volume 26 en \( (0,0,0) \).
Fonctions de forme
Produit tensoriel des fonctions de Lagrange quadratiques 1-D sur \( {-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ù \( (\ell_a, \ell_b, \ell_c) \) sélectionne la position (\( -, 0, + \)) du nœud dans chaque direction. Le centre de volume est \( 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 pour \( \partial_\eta \), \( \partial_\zeta \), avec \( \ell_{-}‘(t) = t - \tfrac12 \), \( \ell_0’(t) = -2t \), \( \ell_{+}’(t) = t + \tfrac12 \).
Quadrature (défaut)
Produit tensoriel 3×3×3 de Gauss-Legendre (27 points), exacte au degré \( \le 5 \) par direction. \( \sum_g w_g = 8 \).
Notes
- Dimensions valides : \( d_r = d_s = 3 \).
- Espace polynomial \( Q2 \) complet : le plus précis des hexaèdres, au prix de 27 nœuds par élément.
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, 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 — les deux seuls opérateurs qui écrivent la géométrie |
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, beam_deformation, shell_deformation, interp_to_gauss (nœuds → Gauss), thermal_strain (déformation thermique EPTH), restrict, restrict_like (reprojection sur le support d’un champ cible), 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 (réassemble depuis les blocs seuls, 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 | integrate_behavior (le COMP) |
ops::solver | Solveur | solve (LU creux, Lagrange), solve_eliminate (condensation MPC), solve_unilateral (actif/inactif, relations unilatérales) |
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 ── deformation ──► 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) |
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) |
extrude(mesh, direction, n_layers) | extrude un maillage le long de direction (SEG2→QUA4, TRI3→PENTA6, QUA4→HEX8) |
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 montées de type ; un tour complet referme l’anneau (voir plus bas) |
sweep(mesh_a, mesh_b, n_layers, element_type="QUA4") | tisse QUA4/TRI3/QUA8/QUA9/TRI6 entre deux lignes SEG2 (un QUA4 est toujours construit d’abord, puis converti) |
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) |
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, all_quad=False, relax="free") | pave l’intérieur des mêmes contours orientés 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, all_quad=False, relax="free") | maille les mêmes contours orientés 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, all_quad=False, relax="free") | même chose, mais les lignes viennent une par nœud du contour et les rangées ont 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 (TRI3/QUA4) en boucles SEG2 (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 sert notamment à fabriquer les supports de multiplicateurs des
contraintes : POI1 → nœuds neufs colocalisés au centre de chaque cellule
(cf. Dirichlet).
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']
sweep : QUA4 par défaut, ou toute variante dérivée
sweep(mesh_a, mesh_b, n_layers, element_type="QUA4") tisse n_layers
couches 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")
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]
)
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 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.
Aucun nœud sur l’axe. Un nœud posé sur l’axe ne bouge pas : toutes les cellules qui s’y appuient s’écraseraient en éléments dégénérés (jacobien nul). L’opérateur le refuse plutôt que de produire un maillage incalculable — il faut décaler la source de l’axe (le trou central d’un disque, l’alésage d’un tube).
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.
Le maillage obtenu se calcule avec l’interpolation LAGRANGE2 (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, all_quad=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 |
| tout-quadrangle garanti | impossible | all_quad=True |
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 :
all_quadsur 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.
Le tout-quadrangle
Laissé à lui-même (all_quad=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 all_quad=True, la parité devient une exigence sur l’entrée : discrétisez
chaque boucle de bord avec un nombre pair de segments, et le résultat est sans
triangle.
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) | 600 | 0,541 | 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é à la taille 1 :
| forme | "free" | "along" | "none" |
|---|---|---|---|
| carré 20 × 20 | 600 mailles, pire 0,541 | 400, 1,000 | 400, 1,000 |
| L | 374, 0,315 | 287, 0,449 | 290, 0,564 |
| profil crénelé | 859, 0,240 | 620, 0,546 | 554, 0,441 |
| bande étroite 40 × 3 | 581, 0,650 | 557, 0,662 | 559, 0,564 |
| cercle R = 10 | 569, 0,105 | 633, 0,257 | 639, 0,020 |
Le cercle est le contre-exemple : "none" y tombe à 0,020, un front qui se
plisse faute de pouvoir se redresser. Sur une courbe, gardez "free".
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 all_quad works.
plaque = pc.mesh.pave_surface(contour, "QUA4", size=0.05, all_quad=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, all_quad=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, all_quad=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 la plaque percée de formation/maillage_test.py, en ajoutant cette
passe (l’angle dièdre médian reste à 47° dans tous les cas) :
| mailles | part sous 10° | part sous 1° | cellule la plus plate / moyenne |
|---|---|---|---|
| 28 000 | 0,59 % → 0,00 % | 0 → 0 | \( 5{,}5\cdot10^{-2} \) → \( 1{,}5\cdot10^{-1} \) |
| 117 000 | 0,81 % → 0,02 % | 0,009 % → 0,000 % | \( 2{,}9\cdot10^{-2} \) → \( 7{,}8\cdot10^{-2} \) |
| 402 000 | 0,94 % → 0,11 % | 0,022 % → 0,000 % | \( 7{,}3\cdot10^{-5} \) → \( 3{,}6\cdot10^{-3} \) |
| 977 000 | 0,91 % → 0,18 % | 0,028 % → 0,001 % | \( 4{,}0\cdot10^{-3} \) → \( 7{,}1\cdot10^{-4} \) |
Le second chiffre 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 environ 1,35× le temps de maillage, à toutes les tailles.
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 la formation, 15 nœuds posés, 12 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 la formation, extrudée puis pelée :
size | facettes | tétraèdres | temps |
|---|---|---|---|
| 0,01 | 3 308 | 28 081 | 1,9 s |
| 0,006 | 8 032 | 117 245 | 7,0 s |
| 0,004 | 17 144 | 405 400 | 24 s |
| 0,003 | 29 604 | 984 415 | 57 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 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 (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(
mecanique, "u_x", gauche, pyrucast.mesh.barycenter(gauche)
)
# 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), …], …]) | idem mais avec une liste de paires par sous-modèle (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, 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). |
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).
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.
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. 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) → ElementField
Déformation linearisée (petites déformations) d’un champ de déplacement :
\[ \varepsilon = \tfrac{1}{2}\big(\nabla u + \nabla u^\top\big). \]
u doit porter exactement space_dim composantes (déplacement selon x, y, z).
Le résultat est le tenseur symétrique en convention tenseur
(eps_xy = ½(∂u_x/∂y + ∂u_y/∂x), pas le cisaillement ingénieur γ), une
composante eps_<ai><aj> par entrée indépendante i ≤ j. C’est l’entrée du
comportement de l’élasticité.
Sur un sous-espace axisymétrique, une
quatrième composante eps_zz est ajoutée : la déformation orthoradiale
ε_θθ = u_r / r, que le gradient méridien ne peut pas exprimer (cf.
Axisymétrie).
thermal_strain(temperature, materials, fespace, t_ref) → 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.
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⟩. Le champ d’entrée doit porter exactement space_dim
composantes (F_x, F_y, F_z) ; chaque sous-espace donne une zone de sortie à
une composante "div". Ce sont des quantités intégrées, pas les valeurs
ponctuelles de ∇·F (pas de projection L²).
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, model) → ElementField
Déformations généralisées d’une coque, depuis les six
DDL u_x…u_z, r_x…r_z. model est la formulation, "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.
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 suivent la même règle d’union que 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.
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. 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 |
sum et xtx 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.
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=None, 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 behavior.integrate, 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=None vaut l’état de repos.
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.
strain = pyrucast.element_field.deformation(u, fes)
Kt = pyrucast.matrix.tangent(model, materials, strain)
Composition : assemble(&mut Matrix)
stiffness produit une matrice portant des blocs calculés (recette, valeurs
produites au scatter) que Matrix::finalize ne sait pas assembler seul. Pour
recomposer — ajouter une SubMatrix de provenance quelconque à une matrice
existante (ou combiner plusieurs Matrix déjà assemblées via l’union |) puis
réassembler — m.assemble(). 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 reconstruit le motif creux depuis les blocs seuls
(sans Model) et redisperse 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 mult = mesh::barycenter(&imposed)?;
let conduction = model::heat_conduction(&fes)?;
let model = conduction.union(&model::dirichlet(
&conduction,
"T",
&imposed,
&mult,
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,
);
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 ; le réassemblage à chaud d’un modèle fixe
reste sur stiffness.
C’est aussi le chemin de composition pour la dynamique : chaque SubMatrix
porte un facteur scalaire paresseux (bloc * s / bloc / s, 1.0 par
défaut — voir Matrice creuse),
et + porte les blocs des deux opérandes sans rien copier, l’assembleur sommant
les contributions qui retombent sur un même DOF. D’où M/dt + K :
sys = m / dt + k
sys.assemble()
u = pyrucast.solver.solve(sys, rhs)
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.
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ᵀ σ).
integrate_behavior(model, deformation, materials, prev=None, 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 construite séparément et géométriquement pargradient(∇T…),deformation(ε),beam_deformation(κ, γ) oushell_deformation(ε, κ, γ) — ces opérateurs ne dépendent que de l’espace EF, pas du modèle ; le choix de quelle déformation nourrir reste donc à l’appelant ; prevest l’état convergé de A — la sortie du pas précédent : la contrainteσ(A), les variables internesVAR(A)et la déformationε(A). Il vautNoneau premier pas, où A est la configuration de référence (σ(A)=0,ε(A)=0) ;dtest l’incrément de temps,Nonepour une loi indépendante du temps (une loi visqueuse future erreurera s’il vautNone) ;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 grandes déformations et les lois visqueuses : elles exigent l’accès àσ(A)et à un incrément daté, que cette interface porte déjà. En petites déformations le prédicteur incrémentalσ_trial = σ(A) + C:Δεest rigoureusement identique à la forme en déformation totale.
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 = None # VAR0 = prev; None at the first step
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) # (κ, γ)
forces = pyrucast.element_field.integrate_behavior(model, eps, materials)
# 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")
forces = pyrucast.element_field.integrate_behavior(model, eps, materials)
# 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ù
deformation 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.
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
sig = pyrucast.element_field.integrate_behavior(model, eps, materials) # 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
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 (Lagrange), une zone porte les
multiplicateurs — les réactions s’y lisent directement.
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
Factorisation réutilisable (cache transparent)
solve factorise une fois, résout N fois : la factorisation est mise en
cache dans la Matrix (état dérivé, non sérialisé, à mutabilité intérieure).
La première résolution factorise et met en cache ; les suivantes sur la même
matrice ne font que la descente/remontée — bien moins cher (cas de charge
multiples, itérations de Newton, transitoire à matrice constante). Le cache est
invalidé automatiquement dès que la matrice change (add_sub). On ne stocke
jamais l’inverse explicite (dense, coûteux, instable) — seulement la
factorisation.
Options de solve :
method— méthode directe ("lu"par défaut ; l’énum laisse la place à d’autres back-ends sans changer les appels) ;cache— réutiliser/peupler le cache (Truepar défaut ;Falsefactorise à neuf sans toucher le cache).
solve_eliminate(matrix, model, rhs) → NodeField
Voie alternative pour un modèle contraint : au lieu de border le système par
des multiplicateurs de Lagrange (ce que fait solve sur la matrice augmentée),
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 LU creux (sur une matrice
plus petite et définie, sans degré multiplicateur), puis prolongé u = T·û + u₀.
- lit la structure des contraintes via le seam méthode-neutre
Constraint::relations()(partagé avec la voie Lagrange) ; leKphysique est extrait du bloc point-selle assemblé (nœuds multiplicateurs filtrés) ; - récupère en post-traitement la réaction (équivalent du multiplicateur),
−(K·u − f)à la ligne duale de chaque esclave (= aₛ·λ) ; - met en cache la condensation (
T,K̂factorisé) sur la matrice, comme la factorisation LU ; mêmes optionsmethod/cache; - un modèle sans contrainte retombe sur un
solvesimple.
Périmètre v1 : non chaîné, esclaves disjoints — chaque relation élimine un esclave distinct, jamais réutilisé comme maître ni esclave ailleurs (couvre la périodicité ; erreur explicite sinon).
K = pyrucast.matrix.stiffness(model, materials)
lagrange = pyrucast.solver.solve(K, rhs) # système augmenté
condense = pyrucast.solver.solve_eliminate(K, model, rhs) # reduced system — same field
Voir l’exemple examples/mpc_condensation.py et la page
Contraintes.
solve_unilateral(matrix, model, rhs) → NodeField
Solveur actif/inactif (méthode du statut) pour un modèle portant des
relations unilatérales (contraintes construites avec sense=">=" /
"<=") : chaque relation est soit active (imposée en égalité, λ = la
réaction), 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 λᵣ porte la réaction), 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é du modèle sont imposées inconditionnellement,
comme par
solve; un modèle sans inégalité retombe sur unsolvesimple ; - la structure des contraintes est lue via le seam méthode-neutre
Constraint::relations()(partagé avec les voies Lagrange et élimination) ; - options :
method/cache(commesolve),active_set(stratégie de factorisation, ci-dessous),max_iter(borne de la boucle de statut,100),tol(tolérance de signe surλet sur le jeu,1e-10).
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_unilateral(K, model, rhs) # "schur" by default
reaction = solution.value(mult_node, "lambda_T") # 0 if the stop is released
# Forcing the old method (refactorization at every step):
sol2 = pyrucast.solver.solve_unilateral(K, model, 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) | solve_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_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 de réaction) 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);
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, letitleexistant reste la légende en haut du graphe (voir plus bas) ; il n’est pas repris en bas.
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.
Thermo-mécanique pas-à-pas (couche Python haut niveau)
Au-dessus des opérateurs de bas niveau (assemblage, comportement, solveur…), pyrucast livre une couche Python pure de plus haut niveau. Elle n’ajoute aucun code Rust : elle orchestre les opérateurs déjà exposés. La première brique est une résolution thermo-mécanique pas-à-pas.
Empaquetage : du Python pur dans le même package
pyrucast est un package mixed Rust/Python (maturin) : l’extension compilée est
le sous-module privé _pyrucast (tous les #[pyfunction]/#[pyclass]), et le
package public pyrucast la ré-exporte tout en ajoutant des modules Python purs.
python/pyrucast/
├── __init__.py # from ._pyrucast import * + ré-export du haut niveau
├── py.typed # PEP 561
├── thermomechanics.py # step_by_step / thermal_step / mechanical_step
└── _pyrucast/… # stub .pyi de l'extension (généré)
import pyrucast donne donc accès à la fois à toute l’API Rust et aux
fonctions Python de plus haut niveau, sans distinction à l’usage :
import pyrucast as pc
pc.matrix.stiffness(...) # opérateur Rust (extension)
pc.thermomechanics.step_by_step(...) # fonction Python pure (thermomechanics.py)
Côté configuration, cela tient à trois lignes de pyproject.toml
([tool.maturin] python-source = "python", module-name = "pyrucast._pyrucast")
et au __init__.py qui fait from ._pyrucast import *.
Modèle de calcul
- Thermique stationnaire par pas. La librairie n’a pas (encore) de terme
transitoire de capacité ; chaque pas résout un problème thermique
stationnaire
K_th · T = charges. La dépendance au temps vient des charges et matériaux, interpolés à l’instant courant (voir Évolution). - Couplage faible, sens unique thermo → méca. La température du pas fournit la
déformation thermique
ε_th(opérateurthermal_strain, Cast3MEPTH), retirée de la déformation totale avant l’intégration de la loi. Il n’y a pas de rétroaction méca → thermique. - Mécanique non linéaire. Newton modifié : l’opérateur d’itération est la
rigidité élastique (assemblée une fois par pas, factorisation mise en cache
par
solve), accéléré par l’accélération d’Anderson (historiquem = 3, garde-fou de descente). L’état interne (plasticité, endommagement…) est propagé d’un pas au suivant via l’interface incrémentaleintegrate_behavior(..., prev=…, dt=…).
Les trois fonctions
| Fonction | Rôle |
|---|---|
step_by_step(data) -> dict | Mise en donnée + boucle sur les instants. Découpe le modèle par physique, appelle thermal_step puis mechanical_step à chaque pas, complète data["results"]. |
thermal_step(thermal_model, materials, loads) -> NodeField | Une résolution thermique stationnaire. |
mechanical_step(mechanical_model, fespace, mesh, materials, loads, temperature, u, state_prev, dt, …) -> (u, out, info) | Une résolution mécanique non linéaire (Newton modifié + Anderson) du pas. |
La découpe par physique s’appuie sur Model.filter
("thermal" / "mechanical" / "constraint") ; les contraintes de Dirichlet
sont ré-attachées à la physique dont elles contraignent une variable (T →
thermique, u_* → mécanique).
Dictionnaire d’entrée / sortie
step_by_step prend un seul dictionnaire et le renvoie complété :
| Clé | Type | Rôle |
|---|---|---|
times | list[float] | instants de calcul |
model | Model | modèle complet (thermique + mécanique + Dirichlet). L’espace EF et le maillage en sont déduits — voir ci-dessous |
loads | NodeField | Evolution | un seul champ unioné : q/imposed_T (thermique) + f_*/imposed_u (mécanique) |
materials | ElementField | Evolution | un seul champ unioné : k/h + E/nu/alpha |
t_ref | float (opt.) | température de référence pour ε_th |
free_mesh | Mesh (opt.) | DDL libres pour la norme de résidu (recommandé avec Dirichlet) |
anderson_depth / max_newton / tol_rel | (opt.) | réglages du solveur mécanique |
Seul le model porte la donnée EF : step_by_step en déduit l’espace et le
maillage mécaniques par Model.fespace() (les sous-espaces des
sous-modèles de domaine, contraintes exclues) puis FiniteElementSpace.mesh().
Chaque étape ne lit du champ unioné que ce dont elle a besoin : solve
n’échantillonne le second membre qu’aux DDL de sa matrice et ignore les
composantes surnuméraires ; material_field remplit par nom les composantes de
chaque physique. Comme thermique et mécanique partagent la fespace, le champ
matériau porte deux zones (aux composantes disjointes) sur le même support ;
nul besoin de les fusionner : les opérateurs (stiffness, integrate_behavior,
thermal_strain) résolvent leur zone matière par les composantes qu’ils
requièrent (k pour la conduction, E/nu pour l’élasticité, alpha pour la
dilatation). Pour fusionner explicitement des zones qui partagent légitimement un
support, element_field.consolidate reste disponible.
En sortie, data["results"] est une liste (un élément par instant) :
{
"time",
"temperature",
"displacement",
"state",
"mech_iters",
"mech_anderson",
"converged",
}
Exemple
import pyrucast as pc
# … maillage `mesh`, `fes`, modèle thermo-mécanique `model`, `materials`, `loads` …
data = {
"times": [0.0, 0.25, 0.5, 0.75, 1.0],
"model": model, # fespace + mesh deduced from the model
"loads": loads, # NodeField unioné ou Evolution de champ
"materials": materials, # ElementField unioné ou Evolution de champ
"t_ref": 20.0,
}
pc.thermomechanics.step_by_step(data)
for r in data["results"]:
print(r["time"], r["mech_iters"], r["converged"])
Démonstration complète (plaque chauffée, dilatation libre, contrôle analytique
u = α·ΔT·x) : examples/thermomecanique_pas_a_pas.py. Passer de
model.elasticity à model.plasticity_perfect suffit pour une mécanique
élasto-plastique — le même appel gère la boucle non linéaire.
Formation débutant
Adaptation, à pyrucast, du plan d’une formation « Débuter avec Cast3M » (présentation du logiciel, langage de commande, maillage, calcul thermique, calcul mécanique, compléments) — le même déroulé pédagogique, mais avec l’API Python de pyrucast et un unique fil rouge : une plaque percée d’un trou, encastrée d’un côté, chargée de l’autre.
Chaque section ci-dessous condense la construction pas à pas
en un script complet, testé, rangé dans le dossier
formation/
du dépôt. Le code affiché dans le livre est inclus directement depuis ces
fichiers (pas de copie manuelle) : ce que vous voyez est ce qui s’exécute.
Sommaire
- Présentation de pyrucast — ce qu’est pyrucast, ce qu’il fait, comment l’installer.
- Python & conventions pyrucast — l’équivalent du chapitre « langage Gibiane » : objets, opérateurs, conventions de nommage.
- Maillage — mailleur non structuré (triangulation avec trou) et mailleur structuré (balayage).
- Calcul thermique — conduction, flux imposé, convection, source volumique, et le repérage géométrique des régions chargées.
- Calcul mécanique — élasticité linéaire, dilatation thermique, plasticité parfaite (pas à pas), contact unilatéral.
- Compléments — éléments structuraux, export de résultats, pour aller plus loin.
Portée. pyrucast ne couvre aujourd’hui que la thermique (conduction, convection) et la mécanique des structures (élasticité, plasticité parfaite, endommagement, contact) — pas de fluides, de magnétostatique ni d’optimisation topologique. Ce chapitre s’y tient : les rubriques du support Cast3M qui n’ont pas d’équivalent testé dans pyrucast sont signalées en encadré, comme celui-ci, plutôt que passées sous silence.
Après la compilation de pyrucast :
pip install maturin
maturin develop --release --features extension-module,viz-interactive
ou son installé à partir de pypi
pip install pyrucast
Chaque script se lance directement depuis la racine du dépôt :
python formation/maillage.py
Présentation de pyrucast
pyrucast, quid ?
Bibliothèque éléments finis écrite en Rust, exposée à Python. Comme Cast3M, elle résout des équations aux dérivées partielles par la méthode des éléments finis — mais c’est un projet bien plus jeune et bien plus étroit : un socle de calcul (maillage, assemblage, résolution), pas un système complet avec pré/post-processeur graphique intégré, ni des décennies de physiques capitalisées.
- Résolution d’équations aux dérivées partielles, comme Cast3M.
- Système en couches, pas un système fermé : maillage, éléments finis, assemblage, comportement, solveur et visualisation sont des modules séparés que l’on compose depuis Python (ou directement depuis Rust — voir Installation).
- Pas de langage de commande dédié. Là où Cast3M invente Gibiane, pyrucast s’utilise en Python ordinaire : les « opérateurs » sont des fonctions, les « objets » sont des classes. Voir Python & conventions pyrucast.
Domaines couverts
Cast3M couvre plus large. Le support Cast3M original liste la mécanique des structures (quasi-statique, contact, dynamique, rupture XFEM), la thermique (conduction/convection/rayonnement/changement de phase), la mécanique des fluides, la diffusion multi-espèces, la fabrication additive, la magnétostatique, le couplage thermo-hygro-mécanique et l’optimisation topologique. pyrucast ne couvre, à ce jour, que les deux premiers points — et partiellement. Le reste (fluides, magnétostatique, diffusion, optimisation topologique, rupture) n’existe pas dans pyrucast ; il n’en sera plus question dans cette formation.
Ce que pyrucast fait réellement :
- Mécanique des structures, quasi-statique, petites déformations : élasticité linéaire, plasticité parfaite de von Mises, endommagement de Mazars, éléments structuraux (barre, poutre de Timoshenko, portique 2D, cadre 3D) ; matrices de masse cohérente et de rigidité géométrique ; contraintes multi-points (MPC), baignage (« embedded »), contact unilatéral nœud-surface.
- Thermique, conduction stationnaire + convection (film/Robin).
Non disponible dans pyrucast. Pas de rayonnement, pas de changement de phase, pas de terme transitoire (capacité) câblé dans une boucle en temps — chaque pas thermique résout un problème stationnaire (voir Calcul thermique pour le détail). Pas de dynamique (temporelle ou modale), pas de flambage, pas de rupture (XFEM). La liste évolue vite : elle sera obsolète avant longtemps, mais reflète l’état au moment de l’écriture de cette formation.
Comment obtenir pyrucast ?
- Multiplateforme : Linux, macOS, Windows — tout ce qu’accepte la chaîne
Rust + Python (
rustup,pip). - Où le télécharger ? Le dépôt du projet (voir le lien en tête de la page Formation débutant).
- Code source : toujours accessible — pyrucast est un projet Rust ordinaire, sans build fermé.
- Prix : logiciel libre.
Comment utiliser pyrucast ?
-
Écrire un script Python — un fichier texte ordinaire, extension
.py. -
Ouvrir un terminal, se placer dans le dépôt cloné.
-
Compiler puis lancer le script :
maturin develop --release python mon_script.py -
Utilisable aussi en mode interactif (
python, ou un notebook) — chaqueimport pyrucastrecharge la même API.
Voir Installation et démarrage rapide pour le détail (prérequis, venv, vérification).
Où trouver la documentation ?
- Ce livre — théorie et référence des objets/opérateurs.
- La documentation de l’API Rust :
cargo doc --no-deps --lib --open. - Les scripts de cette formation : dossier
formation/du dépôt. - Des exemples plus nombreux, un par sujet : dossier
examples/du dépôt.
Python & conventions pyrucast
Cast3M fournit un langage de commande dédié, Gibiane, avec ses propres règles de syntaxe. pyrucast fait le choix inverse : Python ordinaire, sans surcouche — mais avec des conventions de nommage strictes qui jouent le même rôle que la grammaire de Gibiane. Les connaître à l’avance évite de chercher à tâtons dans quel sous-module vit telle fonction.
Détail complet : Correspondance Rust ↔ Python.
Empaquetage
import pyrucast as pc
pyrucast est un paquet mixed Rust/Python (maturin) : l’extension
compilée (tout le calcul) vit dans le sous-module privé pyrucast._pyrucast
; le paquet public la ré-exporte et y ajoute une petite couche Python pure
de plus haut niveau (pyrucast.thermomechanics). À l’usage, aucune
distinction n’est visible :
pc.matrix.stiffness(...) # opérateur Rust (extension compilée)
pc.thermomechanics.step_by_step(...) # fonction Python pure
Les objets : classes au niveau racine
Chaque conteneur — l’équivalent des « objets » Gibiane (MAILLAGE,
CHPOINT, TABLE…) — est une classe Python au niveau racine du paquet,
même nom que la structure Rust :
c = pc.Coords(2) # Cast3M : OPTI 'DIME' 2
n = c.add_node([0.0, 0.0]) # Cast3M : POIN 0. 0. ;
mesh = pc.Mesh(c, "TRI3") # Cast3M : MAILLAGE (implicite via un opérateur)
Onze conteneurs couvrent tout : Coords, Node, Mesh, FiniteElementSpace,
NodeField, ElementField, Model, Matrix, Evolution, plus leurs vues
Sub* (voir plus bas). Contrairement à Gibiane, pas de typage dynamique
surprise : Coords(2) est toujours un Coords, jamais autre chose selon
le contexte.
Les opérateurs : fonctions rangées par thème
Cast3M lit les quatre premiers caractères d’un nom d’opérateur (DROITE ⇔
DROI) et laisse tous les opérateurs dans un espace de noms plat. pyrucast
range chaque verbe (une fonction libre, l’équivalent d’un opérateur
Gibiane) dans un sous-module nommé d’après son thème — miroir direct de
l’arborescence Rust (src/ops/<thème>/) :
| pyrucast (Python) | thème | Cast3M (le plus proche) |
|---|---|---|
pc.mesh.line, pc.mesh.triangulate_surface, pc.mesh.sweep… | maillage | DROITE, SURF, VOLU, TRAN |
pc.element_field.gradient, pc.mesh.select, pc.node_field.mask… | champs | GRAD, MASQUE |
pc.matrix.stiffness, pc.matrix.mass, pc.node_field.external_forces… | assemblage | RIGI, MASS, FLUX/PRES |
pc.element_field.integrate_behavior | comportement | COMP |
pc.solver.solve, pc.solver.solve_unilateral | solveur | RESO |
pc.element_field.material_field | construction | MATE |
pc.export.export_vtk | export | SORT 'VTK' |
Aucun nom raccourci ni forme abrégée : contrairement à Gibiane (DROI ⇔
D), les noms pyrucast sont toujours complets — l’auto-complétion de
l’éditeur remplace l’avantage de la frappe courte.
Composer, pas boucler : agrégats et union |
Sept conteneurs partagent un même protocole d’agrégat — la notion la
plus proche de Gibiane ET :
conduction = pc.model.heat_conduction(fes)
modele = conduction | pc.model.boundary_transfer(bord_fes, conduction, [("T", "q")])
modele = modele | pc.model.dirichlet(modele, "T", impose, multiplicateur)
| unit deux agrégats du même type (Mesh | Mesh, Model | Model,
NodeField | NodeField…) — l’équivalent de ET en Gibiane
(cex = l12 ET c23 ET c34 ...). len(agg), agg[i] (une vue, jamais une
copie), agg.unit() (l’unique sous-objet, erreur sinon) complètent le
protocole. | ne somme pas les contributions d’un nœud partagé : les
zones sont juxtaposées support par support, et à un nœud commun c’est la
première zone définissant le couple (nœud, composante) qui l’emporte ; deux
valeurs différentes lèvent une erreur explicite plutôt que de se sommer
silencieusement. Une véritable superposition demande un support commun et +
(voir la note correspondante dans Calcul thermique).
L’arithmétique de champs (+ - * / **) est réservée aux valeurs
(construire un résidu, une charge scalée) — jamais à la composition
d’agrégats. C’est elle qui remplace la plupart des boucles REPE de
Gibiane : residual = f_ext - f_int, u = u + du, sans jamais itérer nœud
par nœud.
Primal / dual : la convention qui remplace BLOQ/DEPI
Chaque physique déclare une paire variable primale / variable duale par
degré de liberté — u_x/f_x (déplacement/force), T/q
(température/flux), w/f_w (flèche/effort tranchant pour Timoshenko).
Un blocage Dirichlet cible toujours la variable duale :
pc.model.dirichlet(modele, "T", impose, multiplicateur) # Cast3M : BLOQ 'T' ...
mecanique = pc.model.elasticity(fes, "plane_stress")
pc.model.dirichlet(mecanique, "u_x", impose, multiplicateur) # Cast3M : BLOQ 'UX' ...
model.dual_of("u_x") renvoie "f_x" sans avoir à la mémoriser — utile
pour les contraintes MPC, dont chaque terme cible aussi une variable duale.
Pas de mode interactif, pas de procédures Gibiane
- Gibiane bascule en mode interactif sur une ligne vide ou
OPTI 'DONN' 5. Python n’a pas cette notion : un script s’exécute jusqu’au bout, ou on travaille directement dans un interpréteur/notebook. - Les procédures Gibiane (
DEBP/FINP) sont, en Python, de simples fonctions — aucune syntaxe dédiée à apprendre. - Pas de commentaire
*en début de ligne : les commentaires Python commencent par#, comme dans le reste du langage.
La suite de la formation applique ces conventions sur un cas fil rouge — direction Maillage.
Maillage
Fil rouge de la formation : une chape percée — plaque rectangulaire terminée par un demi-disque, trouée en son centre. C’est l’équivalent pyrucast de la pièce « structure avec un trou » de la formation Cast3M originale.
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 formation/maillage.py
; tous les extraits ci-dessous en sont issus directement, dans l’ordre du
fichier.
Géométrie
L’espace de coordonnées
# FR — Un espace de coordonnées 3D, seul objet mutable de tout le script.
# EN — One 3-D coordinate space, the script's only mutable object.
coords = pc.Coords(3)
pyrucast.Coords est le seul objet mutable 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 chapitres suivants 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_mesh` les fusionne.
# EN — `|` keeps one submesh per edge; `consolidate_mesh` 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 planes
# FR — `skin` extrait la peau et la découpe en faces planes, à 85° près.
# EN — `skin` extracts the boundary and splits it into flat faces, at 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
plane : deux facettes voisines restent dans la même face tant que leurs
normales diffèrent de moins de l’angle donné (85° ici). On obtient un
sous-maillage par face — dessus, dessous, chant du trou, chant extérieur —
donc colorable et sélectionnable indépendamment, ce qui sert directement à
poser les conditions aux limites.
(La figure ne montre que les faces intermédiaires, skin[1:-1], 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.
La grille, par balayage du bord gauche
# FR — La grille : le bord gauche balayé par translation, un SEG2 → un QUA4.
# EN — The grid: the left edge swept by translation, a SEG2 → a QUA4.
l15 = pc.mesh.line(p1, p5, n15)
x13 = LENGTH - (HEIGHT / 2.0)
sr1 = pc.mesh.extrude(l15, [x13, 0, 0], n12)
# FR — `plot=False` : les chapitres suivants importent le volume, sans figures.
# EN — `plot=False`: later chapters import the volume, without any figure.
if plot:
show(sr1, "Grille structurée (QUA4)", "maillage-grille.svg")
Balayer plutôt que remplir. extrude(ligne, vecteur, n) balaie une ligne
par translation ; un SEG2 balayé donne un QUA4, d’où une grille régulière
n12 × n15. La grille s’arrête à x13, l’abscisse où commence le demi-disque.
C’est ici qu’apparaît le premier if plot: : les chapitres suivants 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 — The grid's right edge is not rebuilt: it is extracted.
border_sr1 = pc.mesh.border(sr1)
right_nodes = pc.mesh.select(
pc.node_field.positions(border_sr1, ["X"]), ge=x13 * (n12 - 0.5) / 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, une
sélection sur la coordonnée X (field.positions + field.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. 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.
# EN — Its two ends, the lowest and the highest in Z.
p13 = pc.mesh.select(
pc.node_field.positions(l1213, ["Z"]), le=0.5 / n15 * HEIGHT
).node(0, 0, 0)
p12 = pc.mesh.select(
pc.node_field.positions(l1213, ["Z"]), ge=(n15 - 0.5) / 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) : 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.
# EN — The ring's outer loop: 10+10+5+10+5 = 40 segments.
cext = (
pc.mesh.arc(p2, p6, p3, n15)
| pc.mesh.arc(p3, p6, p4, n15)
| pc.mesh.line(p4, p12, int(n15 / 2))
| l1213
| pc.mesh.line(p13, p2, int(n15 / 2))
)
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 — consolidés en une seule boucle fermée, comme pour le contour non structuré.
La boucle intérieure, alignée sur elle
# FR — Boucle intérieure : le trou en quatre quarts, 40 segments aussi.
# EN — Inner loop: the hole as four quarters, 40 segments as well.
p14 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0 - HOLE_RADIUS])
p15 = coords.add_node([LENGTH + HOLE_RADIUS, 0.0, HEIGHT / 2.0])
p16 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0 + HOLE_RADIUS])
p17 = coords.add_node([LENGTH - HOLE_RADIUS, 0.0, HEIGHT / 2.0])
cin = (
pc.mesh.arc(p14, p6, p15, n15)
| pc.mesh.arc(p15, p6, p16, n15)
| pc.mesh.arc(p16, p6, p17, n15)
| pc.mesh.arc(p17, p6, p14, n15)
)
cin = pc.mesh.consolidate(cin)
# 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 : elles doivent donc
avoir le même nombre de segments. D’où le trou découpé en quatre quarts de
10 segments, soit 40 également — et les quatre points de départ p14…p17
posés explicitement pour que les deux découpages s’alignent.
La figure montre les deux boucles seules, l’extérieure en bleu et l’intérieure
en rouge : 40 segments de chaque côté, et les découpages en vis-à-vis. C’est
exactement ce que sweep demande.
sweep, puis l’union
# FR — `sweep` relie les deux boucles par 3 couches de QUA4.
# EN — `sweep` links both loops with 3 layers of QUA4.
sh1 = pc.mesh.sweep(cext, cin, 3)
if plot:
show(sh1, "Couronne balayée (QUA4)", "maillage-couronne.svg")
# FR — Grille et couronne partagent les nœuds du bord droit : `|` suffit.
# EN — Grid and ring share the right edge's nodes: `|` is enough.
grid = sr1 | sh1
if plot:
print(f"structured : {grid.element_types()}, {grid.cell_count()} mailles")
show(grid, "Plaque structurée (QUA4)", "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 nœuds donnent ici 40 × 3 = 120 QUA4.
Grille et couronne partageant les nœuds du bord droit l1213, l’union |
suffit ensuite à en faire un maillage conforme — 200 + 120 = 320 QUA4 :
Le volume HEX8
# FR — Même extrusion qu'en non structuré, mais un QUA4 donne un HEX8.
# EN — Same extrusion as in the unstructured case, but a QUA4 gives an HEX8.
volume_structure = pc.mesh.extrude(grid, [0, THICKNESS, 0], 2)
if plot:
show(
volume_structure,
"Volume structuré (HEX8)",
"maillage-volume-structure.svg",
)
return grid, volume_structure
La même extrusion que pour le non structuré donne le volume — mais un QUA4
balayé donne un HEX8, le meilleur élément pour le calcul :
C’est ce volume que reprennent les chapitres suivants. 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
Une seule méthode, plot(...), sur Mesh/SubMesh — l’équivalent de
TRAC :
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 (nécessite la feature Cargo
viz-interactive) ; save="....png" ou "....svg" exporte sans fenêtre
(feature viz) — voir Visualisation pour le détail
(caméra, colormaps, coloration par champ).
Le script réunit les deux dans un petit helper, pour que chaque tracé soit interactif à l’exécution et devienne une figure de cette page en mode batch :
# 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_FORMATION_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 cette formation (img/*.svg) sont pré-générées à partir
des scripts formation/*.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-formation-figures.sh
Script complet
Une fois les explications retirées, tout le chapitre tient en une page — du premier point guide au volume structuré :
"""Formation débutant — 1. Maillage. / Beginner training — 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 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 step-by-step walkthrough lives in the book's meshing
page.
Lancement / Running ::
maturin develop --release
python formation/maillage.py
# Figures du livre / book figures (book/src/formation/img/) :
# PYRUCAST_FORMATION_IMG_DIR=book/src/formation/img python formation/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_FORMATION_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, seul objet mutable de tout le script.
# EN — One 3-D coordinate space, the script's only mutable object.
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_mesh` les fusionne.
# EN — `|` keeps one submesh per edge; `consolidate_mesh` 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 planes, à 85° près.
# EN — `skin` extracts the boundary and splits it into flat faces, at 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, un SEG2 → un QUA4.
# EN — The grid: the left edge swept by translation, a SEG2 → a QUA4.
l15 = pc.mesh.line(p1, p5, n15)
x13 = LENGTH - (HEIGHT / 2.0)
sr1 = pc.mesh.extrude(l15, [x13, 0, 0], n12)
# FR — `plot=False` : les chapitres suivants importent le volume, sans figures.
# EN — `plot=False`: later chapters import the volume, without any figure.
if plot:
show(sr1, "Grille structurée (QUA4)", "maillage-grille.svg")
# FR — Le bord droit de la grille ne se refabrique pas : il s'extrait.
# EN — The grid's right edge is not rebuilt: it is extracted.
border_sr1 = pc.mesh.border(sr1)
right_nodes = pc.mesh.select(
pc.node_field.positions(border_sr1, ["X"]), ge=x13 * (n12 - 0.5) / 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.
# EN — Its two ends, the lowest and the highest in Z.
p13 = pc.mesh.select(
pc.node_field.positions(l1213, ["Z"]), le=0.5 / n15 * HEIGHT
).node(0, 0, 0)
p12 = pc.mesh.select(
pc.node_field.positions(l1213, ["Z"]), ge=(n15 - 0.5) / n15 * HEIGHT
).node(0, 0, 0)
# FR — Boucle extérieure de la couronne : 10+10+5+10+5 = 40 segments.
# EN — The ring's outer loop: 10+10+5+10+5 = 40 segments.
cext = (
pc.mesh.arc(p2, p6, p3, n15)
| pc.mesh.arc(p3, p6, p4, n15)
| pc.mesh.line(p4, p12, int(n15 / 2))
| l1213
| pc.mesh.line(p13, p2, int(n15 / 2))
)
cext = pc.mesh.consolidate(cext)
# FR — Boucle intérieure : le trou en quatre quarts, 40 segments aussi.
# EN — Inner loop: the hole as four quarters, 40 segments as well.
p14 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0 - HOLE_RADIUS])
p15 = coords.add_node([LENGTH + HOLE_RADIUS, 0.0, HEIGHT / 2.0])
p16 = coords.add_node([LENGTH, 0.0, HEIGHT / 2.0 + HOLE_RADIUS])
p17 = coords.add_node([LENGTH - HOLE_RADIUS, 0.0, HEIGHT / 2.0])
cin = (
pc.mesh.arc(p14, p6, p15, n15)
| pc.mesh.arc(p15, p6, p16, n15)
| pc.mesh.arc(p16, p6, p17, n15)
| pc.mesh.arc(p17, p6, p14, n15)
)
cin = pc.mesh.consolidate(cin)
# 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 QUA4.
# EN — `sweep` links both loops with 3 layers of QUA4.
sh1 = pc.mesh.sweep(cext, cin, 3)
if plot:
show(sh1, "Couronne balayée (QUA4)", "maillage-couronne.svg")
# FR — Grille et couronne partagent les nœuds du bord droit : `|` suffit.
# EN — Grid and ring share the right edge's nodes: `|` is enough.
grid = sr1 | sh1
if plot:
print(f"structured : {grid.element_types()}, {grid.cell_count()} mailles")
show(grid, "Plaque structurée (QUA4)", "maillage-structure.svg")
# FR — Même extrusion qu'en non structuré, mais un QUA4 donne un HEX8.
# EN — Same extrusion as in the unstructured case, but a QUA4 gives an HEX8.
volume_structure = pc.mesh.extrude(grid, [0, THICKNESS, 0], 2)
if plot:
show(
volume_structure,
"Volume structuré (HEX8)",
"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 : Calcul thermique, qui reprend le volume HEX8
structuré de cette page et y pose des conditions aux limites.
Calcul thermique
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 formation/thermique.py
; tous les extraits ci-dessous en sont issus directement, dans l’ordre du
fichier.
L’équation résolue
Le bilan d’énergie sur un volume V s’écrit, avec φ 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) \]
En régime stationnaire le premier terme disparaît : il reste \( \operatorname{div}(-\lambda \operatorname{grad} T) = q \), complété par les conditions aux limites — température imposée sur une partie du bord, flux sur l’autre :
\[ T = T_{\text{imp}} \ \text{ sur } \partial V_T, \qquad \phi \cdot n = \phi_{\text{imp}} + h\,(T - T_f) \ \text{ sur } \partial V_\phi \]
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 \]
Chaque terme correspond à un objet du script :
| Terme | pyrucast |
|---|---|
| \( \int_V [B]^T \lambda [B] \, dV \) | model.heat_conduction(fes), assemblé par pc.matrix.stiffness |
| \( \int_{\partial V_\phi} h [N]^T [N] \, dS \) | model.boundary_transfer(fes, conduction, [("T", "q")]), dans la même matrice |
| \( T = T_{\text{imp}} \) | model.dirichlet(conduction, "T", ...) (multiplicateurs de Lagrange) |
| \( \int_{\partial V_\phi} [N]^T \phi_{\text{imp}} \, dS \) | pc.model.flux(fes, modele, "q") sur des QUA4, densité phi_q |
| \( \int_{\partial V_\phi} [N]^T h\,T_f \, dS \) | pc.model.boundary_transfer, ambiant a_ext_T |
| \( \int_V [N]^T q \, dV \) | pc.model.flux(fes, modele, "q") sur des HEX8 |
Les trois dernières lignes disent le point notable : en pyrucast, flux est
l’unique opérateur de charge répartie, quelle que soit la dimension du
sous-espace éléments finis sur lequel on l’applique — une face QUA4 intègre
une densité surfacique, un HEX8 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
formation/maillage.py
et calcule sur le volume HEX8 structuré du chapitre précédent — 640
hexaèdres.
# FR — Le maillage du chapitre 1, tel quel ; `plot=False` : pas ses figures.
# EN — Chapter 1'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 du
chapitre 1.
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, 120 nœuds.
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 120 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 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 QUA4 portés.
# EN — The left face: the nodes of the plane x = 0, then the QUA4 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 QUA4 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é, et un jumeau pour les multiplicateurs.
# EN — Dirichlet: the constrained support, and a twin for the multipliers.
multiplicateur_T = pc.mesh.translate(alesage, [0.0, 0.0, 0.0])
modele = modele | pc.model.dirichlet(modele, "T", alesage, multiplicateur_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)]
)
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 passe par des multiplicateurs de Lagrange : le système résolu n’est plus \( [K]\{T\} = \{P\} \) mais
\[ \begin{bmatrix} K & C^T \\ C & 0 \end{bmatrix} \begin{Bmatrix} T \\ \lambda \end{Bmatrix} = \begin{Bmatrix} P \\ T_{\text{imp}} \end{Bmatrix} \]
où \( C \) est la relation \( T = T_{\text{imp}} \) sur les nœuds de
l’alésage. D’où les deux maillages POI1 donnés à model.dirichlet : le
support bloqué (l’alésage, tel que points_on_cylinder l’a renvoyé) et un
jumeau qui porte les inconnues \( \lambda \), obtenu par copie translatée de
zéro — deux jeux de nœuds distincts, donc deux jeux d’inconnues. La solution
renvoyée contient les deux, et les multiplicateurs sont les réactions (ici
les flux qu’il faut injecter pour tenir l’alésage à 250 °C).
Le champ matériau, lui, se construit à partir du modèle : material_field
sait quels coefficients celui-ci réclame, et la conduction n’en demande qu’un,
« k ».
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(multiplicateur_T, ["imposed_T"])
temperature_imposee[0].add_to_component("imposed_T", T_IMPOSEE)
Le flux imposé est un pur second membre : pc.model.flux intègre
\( \int [N]^T \phi_{\text{imp}} \, dS \) sur les 20 QUA4 de la face
gauche et rend un champ nodal. L’espace éléments finis se construit sur le
sous-maillage de la face, et gauche_fes[0] en désigne l’unique zone.
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 ci-dessus, en face des inconnues \( \lambda \).
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 QUA4 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 HEX8 au lieu de QUA4. 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", "q")]
)
modele = modele | pc.model.dirichlet(modele, "T", alesage, multiplicateur_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
model.boundary_transfer(basse_fes, conduction, [("T", "q")]), 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 est repris tel
quel, avec les mêmes deux maillages POI1.
Un seul material_field couvre le tout — « k » est réclamé par la conduction,
« h » 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 HEX8, 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 HEX8 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 : c’est le
même opérateur flux que pour le flux imposé, sur la surface convectée.
La source volumique, elle, est le terme \( \int_V [N]^T q \, dV \) : encore
flux, mais appliqué à des HEX8. La dimension de l’intégrale est celle des
éléments qu’on lui donne, donc une densité volumique ici.
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 sont recopiés tels quels), et elle ne fait même pas la vérification de cohérence de|. 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 Champs détaille cette algèbre ; le chapitre 3 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 vaut la
somme des trois puissances prises séparément, ce que l’union perdrait.
La température imposée, elle, vit sur le maillage des multiplicateurs,
translaté donc disjoint de tous les autres : 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 du chapitre 1 c’est la convection qui l’emporte : rien ne passe au-dessus de la température de l’alésage.
Non disponible dans pyrucast.
- Rayonnement. Pas de condition de bord de type
ϕ·n = εσ(T⁴∞ − T⁴)— seules conduction et convection (film/Robin) existent.- Régime transitoire. La matrice de capacité \( [C] = \int_V \rho c_p [N]^T [N] \, dV \) est assemblable (
pyrucast.matrix.mass), mais rien ne la relie encore à une boucle en temps : chaque pas résout \( [K]\{T\} = \{P\} \) stationnaire (pas de \( [C]\{\dot{T}\} + [K]\{T\} = \{P\} \) intégré en temps). UnEvolutionpeut faire varier un chargement stationnaire d’un pas à l’autre (voir Calcul mécanique pour ce mécanisme appliqué à la mécanique), mais c’est une suite de problèmes stationnaires indépendants, pas une intégration temporelle.
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 :
"""Formation débutant — 2. Calcul thermique. / Beginner training — 2. Thermal.
FR — Reprend la **chape percée** du chapitre 1 — le volume HEX8 structuré est
importé de `formation/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`). Ni
rayonnement ni terme transitoire. Le détail pas à pas est dans le livre, page
« Calcul thermique ».
EN — Picks chapter 1's **pierced lug** back up — the structured HEX8 volume is
imported from `formation/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`). No radiation, no
transient term. The step-by-step walkthrough lives in the book's thermal page.
Lancement / Running ::
maturin develop --release
python formation/thermique.py
# Figures du livre / book figures (book/src/formation/img/) :
# PYRUCAST_FORMATION_IMG_DIR=book/src/formation/img python formation/thermique.py
"""
import os
import pyrucast as pc
from maillage import HEIGHT, HOLE_RADIUS, LENGTH, OUT, THICKNESS, show, structured_mesh
# FR — Vue commune aux figures du chapitre 1 : azimut, élévation, échelle.
# EN — The view shared by chapter 1'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 du chapitre 1, tel quel ; `plot=False` : pas ses figures.
# EN — Chapter 1'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 QUA4 portés.
# EN — The left face: the nodes of the plane x = 0, then the QUA4 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é, et un jumeau pour les multiplicateurs.
# EN — Dirichlet: the constrained support, and a twin for the multipliers.
multiplicateur_T = pc.mesh.translate(alesage, [0.0, 0.0, 0.0])
modele = modele | pc.model.dirichlet(modele, "T", alesage, multiplicateur_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(multiplicateur_T, ["imposed_T"])
temperature_imposee[0].add_to_component("imposed_T", 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", "q")]
)
modele = modele | pc.model.dirichlet(modele, "T", alesage, multiplicateur_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 HEX8, 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 HEX8 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()} HEX8")
print(f"face gauche : {face_gauche.cell_count()} QUA4")
print(f"face convectée : {face_basse.cell_count()} QUA4")
print(f"zone chauffée : {zone_source.cell_count()} HEX8")
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 : Calcul mécanique, qui réutilise ce champ de température.
Calcul mécanique
Toujours la même plaque trouée : bord gauche encastré, effort réparti sur la moitié basse du trou (la « masse suspendue » du TD Cast3M). Trois volets, dans le même ordre que le support original — élasticité linéaire (sections 6-7-8), plasticité non linéaire pas à pas (section 9), contact unilatéral (section 10).
1. Élasticité linéaire
def construire_plaque_trouee():
"""A holed rectangular plate, built edge by edge with the
mailleurs dédiés (`line`, `circle`), fusionnés en un seul
submeshes by `pyrucast.mesh.consolidate` before `triangulate_surface` — as
in `formation/maillage.py`. Also returns the submeshes the mechanics and
the thermics need: left edge (clamped end), lower half of the hole
(loading) and the whole hole (imposed temperature, reused as is to stay on
the same nodes as `plaque`)."""
coords = pc.Coords(2)
p1 = coords.add_node([0.0, 0.0])
p2 = coords.add_node([LONGUEUR, 0.0])
p3 = coords.add_node([LONGUEUR, HAUTEUR])
p4 = coords.add_node([0.0, HAUTEUR])
bas = pc.mesh.line(p1, p2, 10)
droit = pc.mesh.line(p2, p3, 4)
haut = pc.mesh.line(p3, p4, 10)
gauche = pc.mesh.line(p4, p1, 4)
boucle_ext = pc.mesh.consolidate(bas | droit | haut | gauche)
centre = coords.add_node(list(CENTRE_TROU))
trou = pc.mesh.circle(centre, [0.0, 0.0, 1.0], RAYON_TROU, 16)
# Outer loop CCW, hole clockwise (CW): the orientation
# `triangulate_surface` expects (the hole is inverted, `trou` stays usable below).
contour = boucle_ext | pc.mesh.invert(trou)
plaque = pc.mesh.triangulate_surface(contour, "TRI3", size=0.02)
# Lower half of the hole (y < centre): support of the hung mass's force,
# like Cast3M's `PRES 'MASS'` over half the circle.
y = pc.node_field.positions(trou, ["Y"])
noeuds_bas_trou = pc.mesh.select(y, lt=CENTRE_TROU[1])
arc_bas = pc.mesh.elements_on(trou, noeuds_bas_trou, strict=True)
return coords, plaque, gauche, arc_bas, trou
Le modèle : élasticité (contraintes planes), encastrement u_x = u_y = 0
sur le bord gauche, effort réparti sur l’arc bas du trou (Cast3M
FSUR 'MASS'/PRES 'MASS', ici pyrucast.model.flux en composante
f_y) :
encastrement = pc.mesh.to_poi1(gauche)
multiplicateur = pc.mesh.translate(encastrement, [0.0, 0.0])
modele = pc.model.elasticity(fes, "plane_stress")
modele = modele | pc.model.dirichlet(modele, "u_x", encastrement, multiplicateur)
modele = modele | pc.model.dirichlet(modele, "u_y", encastrement, multiplicateur)
# The hung mass's force, spread over the lower half of the hole —
# analogue de FSUR 'MASS' / PRES 'MASS' (Cast3M section 6).
pression = -MASSE * G / (2.0 * 3.14159265 * RAYON_TROU)
modele = modele | pc.model.flux(arc_fes, modele, "f_y")
materiaux = pc.element_field.material_field(
modele, [("E", E), ("nu", NU), ("alpha", ALPHA), ("phi_f_y", pression)]
)
effort = pc.node_field.external_forces(modele, materiaux)
K = pc.matrix.stiffness(modele, materiaux)
u1 = pc.solver.solve(K, effort)
print(f"1) élasticité seule : u_y(trou) ≈ {u1.min('u_y'):.6e} m")
2. + Dilatation thermique
On réutilise le champ de température résolu comme en
Calcul thermique (température imposée sur le bord du trou)
pour calculer une déformation thermique et l’ajouter au chargement —
l’équivalent Cast3M EPTH :
temperature = resoudre_thermique(plaque, trou)
t_gauss = pc.element_field.interp_to_gauss(
pc.node_field.restrict(temperature, plaque), fes
)
eps_th = pc.element_field.thermal_strain(t_gauss, materiaux, fes, T_REF)
sig_th = pc.element_field.integrate_behavior(modele, eps_th, materiaux)
f_th = (
pc.node_field.divergence(sig_th, "sigma")
.rename_component("div_sigma_x", "f_x")
.rename_component("div_sigma_y", "f_y")
)
second_membre = f_th + pc.node_field.restrict_like(effort, f_th)
u2 = pc.solver.solve(K, second_membre)
print(f"2) + dilatation thermique : u_y(trou) ≈ {u2.min('u_y'):.6e} m")
Trois briques, à la main, sans opérateur « tout-en-un » — comme en Cast3M
(EPTH + ELAS + BSIG) :
pyrucast.element_field.thermal_strain: \( \varepsilon_{\text{th}} = \alpha \cdot (T - T_{\text{ref}}) \), la même formule que Cast3MEPTH, à partir d’un champ de température aux points de Gauss (pyrucast.element_field.interp_to_gauss) ;pyrucast.element_field.integrate_behavior: la pseudo-contrainte thermique \( \sigma_{\text{th}} = D : \varepsilon_{\text{th}} \) ;pyrucast.node_field.internal_forces: la charge nodale équivalente \( F_{\text{th}} = \int B^T \sigma_{\text{th}} \, dV \) (Cast3MBSIG).
Le second membre se combine par addition de champs (+), pas par union
(|) : internal_forces couvre tous les nœuds mécaniques, l’effort
extérieur n’en couvre qu’une partie — pyrucast.node_field.restrict_like
étend l’un sur le support de l’autre avant de les additionner. C’est la même
distinction que la note de la page Calcul thermique : |
pour des supports disjoints, +/- pour une véritable superposition sur un
support commun.
Pour aller plus loin — matériau hétérogène (Cast3M section 8). Cast3M fait varier
α(x)par une formule évaluée sur un champ aux points de Gauss (loi normale centrée sur la plaque). pyrucast le permettrait de la même façon : unElementFieldaccepte des valeurs non uniformes par(cellule, point de Gauss), etpyrucast.field.exp/l’arithmétique de champs (+ - * **) suffiraient à coder la formule — mais ce script ne le met pas en œuvre, faute d’un exemple testé à ce jour dans cette formation.
3. Plasticité parfaite — pas à pas
Le chargement dépasse maintenant la limite élastique. Comme Cast3M pilote
ce cas par la procédure PASAPAS, pyrucast fournit
pyrucast.thermomechanics.step_by_step : la boucle sur les pas de charge,
un Newton modifié (rigidité élastique, réassemblée une fois par pas) et
son accélération d’Anderson. Il suffit de remplacer
model.elasticity par model.plasticity_perfect :
encastrement = pc.mesh.to_poi1(bord_gauche)
multiplicateur = pc.mesh.translate(encastrement, [0.0, 0.0])
modele = pc.model.plasticity_perfect(fes, "plane_stress")
modele = modele | pc.model.dirichlet(modele, "u_x", encastrement, multiplicateur)
modele = modele | pc.model.dirichlet(modele, "u_y", encastrement, multiplicateur)
L’historique de charge est un Evolution à valeur champ, interpolée
linéairement en pseudo-temps — Cast3M EVOL 'MANU' :
pression = -FACTEUR_CHARGE * MASSE * G / (2.0 * 3.14159265 * RAYON_TROU)
modele = modele | pc.model.flux(arc_fes, modele, "f_y")
materiaux = pc.element_field.material_field(
modele, [("E", E), ("nu", NU), ("sigma_y", SIGMA_Y), ("phi_f_y", pression)]
)
effort_final = pc.node_field.external_forces(modele, materiaux)
charge = pc.Evolution(
[(0.0, effort_final * 0.0), (1.0, effort_final)], out_of_range="clamp"
)
# Free DOFs (outside the clamped end) to norm the Newton residual —
# without which the large support reactions mask the real convergence.
x = pc.node_field.positions(plaque, ["X"])
ddl_libres = pc.mesh.select(x, gt=1e-6)
data = {
"times": [0.0, 0.2, 0.4, 0.55, 0.7], # pseudo-temps ∈ [0, 1]
"model": modele,
"loads": charge,
"materials": materiaux,
"free_mesh": ddl_libres,
"max_newton": 200,
}
pc.thermomechanics.step_by_step(data)
Piège pyrucast. Sans
free_mesh, la norme du résidu de Newton porte sur tous les nœuds, y compris les nœuds encastrés — dont la réaction d’appui, potentiellement énorme, empêche toute convergence.free_meshrestreint la norme aux degrés de liberté réellement libres (ici : tous les nœuds d’abscissex > 0). C’est l’équivalent, en plus explicite, du traitement automatique des blocages par Cast3M dansRESO/PASAPAS.
Non disponible dans pyrucast.
model.plasticity_perfectne consomme pas encore la composante matériau optionnellealpha— la dépendance desigma_yà la température (Cast3M section 9.2 :EVOL 'MANU' 'T' ... 'SIGY' ...) n’a donc pas d’équivalent testé ici ; seule la plasticité isotherme est couverte.
4. Contact unilatéral
Cast3M pilote aussi le contact par PASAPAS (table tab3, section 10).
pyrucast ne compose pas encore thermique + plasticité + contact dans un
même appel step_by_step ; le contact se résout directement par le solveur
actif-set pyrucast.solver.solve_unilateral, sur un patch-test classique
(deux blocs superposés, jeu initial, pression sur le bloc du haut) :
coords = pc.Coords(2)
mesh_bas, bas = bloc(coords, 0.0)
mesh_haut, haut = bloc(coords, 1.0 + G0)
mesh = mesh_bas | mesh_haut
fes = pc.FiniteElementSpace(mesh)
# Master: top edge of the lower block (`contour` already orients the
# boundary counter-clockwise, so this edge naturally runs right to left —
# the associated normal points towards +y). Slave: nodes of the upper
# block's bottom edge.
maitre = bord_horizontal(mesh_bas, 1.0)
esclave = pc.mesh.poi1_from_nodes([haut[idx(i, 0)] for i in range(N + 1)])
elasticite = pc.model.elasticity(fes, "plane_stress")
contact = pc.model.contact(elasticite, esclave, maitre, ["u_x", "u_y"])
modele = pc.model.elasticity(fes, "plane_stress")
modele = modele | clamp(modele, bas + haut, "u_x")
modele = modele | clamp(modele, [bas[idx(i, 0)] for i in range(N + 1)], "u_y")
modele = modele | contact
bord_haut = bord_horizontal(mesh_haut, 2.0 + G0)
bord_haut_fes = pc.FiniteElementSpace(bord_haut)
modele = modele | pc.model.flux(bord_haut_fes, modele, "f_y")
materiaux = pc.element_field.material_field(
modele, [("E", E), ("nu", 0.0), ("phi_f_y", -S)]
)
traction = pc.node_field.external_forces(modele, materiaux)
# `contact_gaps()` supplies the contact's right-hand side — the Cast3M
# equivalent of preparing the unilateral problem before RESO.
second_membre = traction | modele.contact_gaps()
K = pc.matrix.stiffness(modele, materiaux)
solution = pc.solver.solve_unilateral(K, modele, second_membre)
model.contact_gaps() fournit le second membre associé au contact — jeu
initial à combler avant que les multiplicateurs lambda_contact ne portent
une réaction non nulle. Voir
Contact (nœud-surface) pour le détail
mathématique (formulation active-set, jeux, multiplicateurs).
Scripts complets
"""Formation débutant — 3. Calcul mécanique (élasticité linéaire).
Reprend la plaque trouée : bord gauche encastré, effort ponctuel (masse
mass) spread over the lower half of the hole. Three load cases follow one
another, like sections 6/7/8 of the Cast3M training:
1. **élasticité pure** — effort seul ;
2. **+ dilatation thermique** — on réutilise le champ de température de
`formation/thermique.py` (`ε_th = α·(T − T_ref)`, opérateur
`field.thermal_strain`, l'équivalent Cast3M `EPTH`) ;
3. a paragraph (no code tested here) on the **heterogeneous material**
(Cast3M varies `alpha(x)` through a formula on a field at the Gauss
points) — see the book page for the detail.
Lancement ::
maturin develop --release
python formation/mecanique.py
# To regenerate the book figure (book/src/formation/img/):
# PYRUCAST_FORMATION_IMG_DIR=book/src/formation/img python formation/mecanique.py
"""
import os
import tempfile
import pyrucast as pc
LONGUEUR, HAUTEUR = 0.30, 0.10 # m
RAYON_TROU = 0.025 # m
CENTRE_TROU = (0.75 * LONGUEUR, HAUTEUR / 2.0)
E, NU, ALPHA = 200e9, 0.3, 1e-5 # acier
MASSE, G = 2500.0, 9.81 # kg, m/s^2 — mass hung from the hole
T_REF, T_IMPOSEE = 20.0, 250.0 # °C — dilatation thermique
K_COND = 50.0 # W/m/K
# ANCHOR: construction
def construire_plaque_trouee():
"""A holed rectangular plate, built edge by edge with the
mailleurs dédiés (`line`, `circle`), fusionnés en un seul
submeshes by `pyrucast.mesh.consolidate` before `triangulate_surface` — as
in `formation/maillage.py`. Also returns the submeshes the mechanics and
the thermics need: left edge (clamped end), lower half of the hole
(loading) and the whole hole (imposed temperature, reused as is to stay on
the same nodes as `plaque`)."""
coords = pc.Coords(2)
p1 = coords.add_node([0.0, 0.0])
p2 = coords.add_node([LONGUEUR, 0.0])
p3 = coords.add_node([LONGUEUR, HAUTEUR])
p4 = coords.add_node([0.0, HAUTEUR])
bas = pc.mesh.line(p1, p2, 10)
droit = pc.mesh.line(p2, p3, 4)
haut = pc.mesh.line(p3, p4, 10)
gauche = pc.mesh.line(p4, p1, 4)
boucle_ext = pc.mesh.consolidate(bas | droit | haut | gauche)
centre = coords.add_node(list(CENTRE_TROU))
trou = pc.mesh.circle(centre, [0.0, 0.0, 1.0], RAYON_TROU, 16)
# Outer loop CCW, hole clockwise (CW): the orientation
# `triangulate_surface` expects (the hole is inverted, `trou` stays usable below).
contour = boucle_ext | pc.mesh.invert(trou)
plaque = pc.mesh.triangulate_surface(contour, "TRI3", size=0.02)
# Lower half of the hole (y < centre): support of the hung mass's force,
# like Cast3M's `PRES 'MASS'` over half the circle.
y = pc.node_field.positions(trou, ["Y"])
noeuds_bas_trou = pc.mesh.select(y, lt=CENTRE_TROU[1])
arc_bas = pc.mesh.elements_on(trou, noeuds_bas_trou, strict=True)
return coords, plaque, gauche, arc_bas, trou
# ANCHOR_END: construction
def resoudre_thermique(plaque, trou):
"""Ré-sout la thermique de `formation/thermique.py` (version simplifiée,
without convection or source, just T imposed on the hole) so as to reuse
a non-uniform field in the second load case below.
Important: we reuse the `trou` returned by `construire_plaque_trouee` —
hence the same nodes as the hole's edge in `plaque` — rather than
rebuilding a separate circle, which would give nodes disjoint from the
real mesh and a Dirichlet with no effect on the solution."""
fes = pc.FiniteElementSpace(plaque)
modele_th = pc.model.heat_conduction(fes)
trou_poi1 = pc.mesh.to_poi1(trou)
multiplicateur = pc.mesh.translate(trou_poi1, [0.0, 0.0])
modele_th = modele_th | pc.model.dirichlet(
modele_th, "T", trou_poi1, multiplicateur
)
materiaux_th = pc.element_field.material_field(modele_th, [("k", K_COND)])
temperature_imposee = pc.NodeField(multiplicateur, ["imposed_T"])
temperature_imposee[0].add_to_component("imposed_T", T_IMPOSEE)
K_th = pc.matrix.stiffness(modele_th, materiaux_th)
return pc.solver.solve(K_th, temperature_imposee)
def main() -> None:
_coords, plaque, gauche, arc_bas, trou = construire_plaque_trouee()
fes = pc.FiniteElementSpace(plaque)
arc_fes = pc.FiniteElementSpace(arc_bas)
# ANCHOR: modele_elastique
encastrement = pc.mesh.to_poi1(gauche)
multiplicateur = pc.mesh.translate(encastrement, [0.0, 0.0])
modele = pc.model.elasticity(fes, "plane_stress")
modele = modele | pc.model.dirichlet(modele, "u_x", encastrement, multiplicateur)
modele = modele | pc.model.dirichlet(modele, "u_y", encastrement, multiplicateur)
# The hung mass's force, spread over the lower half of the hole —
# analogue de FSUR 'MASS' / PRES 'MASS' (Cast3M section 6).
pression = -MASSE * G / (2.0 * 3.14159265 * RAYON_TROU)
modele = modele | pc.model.flux(arc_fes, modele, "f_y")
materiaux = pc.element_field.material_field(
modele, [("E", E), ("nu", NU), ("alpha", ALPHA), ("phi_f_y", pression)]
)
effort = pc.node_field.external_forces(modele, materiaux)
K = pc.matrix.stiffness(modele, materiaux)
# ANCHOR_END: modele_elastique
# ANCHOR: cas1_elastique
u1 = pc.solver.solve(K, effort)
print(f"1) élasticité seule : u_y(trou) ≈ {u1.min('u_y'):.6e} m")
# ANCHOR_END: cas1_elastique
# ANCHOR: cas2_thermique
temperature = resoudre_thermique(plaque, trou)
t_gauss = pc.element_field.interp_to_gauss(
pc.node_field.restrict(temperature, plaque), fes
)
eps_th = pc.element_field.thermal_strain(t_gauss, materiaux, fes, T_REF)
sig_th = pc.element_field.integrate_behavior(modele, eps_th, materiaux)
f_th = (
pc.node_field.divergence(sig_th, "sigma")
.rename_component("div_sigma_x", "f_x")
.rename_component("div_sigma_y", "f_y")
)
second_membre = f_th + pc.node_field.restrict_like(effort, f_th)
u2 = pc.solver.solve(K, second_membre)
print(f"2) + dilatation thermique : u_y(trou) ≈ {u2.min('u_y'):.6e} m")
# ANCHOR_END: cas2_thermique
# u2 also carries the Dirichlet's Lagrange multipliers: only (u_x, u_y)
# are kept before computing a strain.
u2_propre = pc.node_field.restrict_like(u2, pc.NodeField(plaque, ["u_x", "u_y"]))
contraintes = pc.element_field.integrate_behavior(
modele, pc.element_field.deformation(u2_propre, fes) - eps_th, materiaux
)
print(f" σ_xx max ≈ {contraintes.max('sigma_xx'):.3e} Pa")
out = os.environ.get("PYRUCAST_FORMATION_IMG_DIR", tempfile.gettempdir())
chemin = os.path.join(out, "mecanique-deplacement.svg")
plaque.plot(save=chemin, field=u2, component="u_y", cmap="coolwarm", smooth=1)
print(f"Displacement u_y written to {chemin}")
if __name__ == "__main__":
main()
"""Formation débutant — 4. Mécanique non linéaire (plasticité).
Picks the holed plate clamped on the left back up, with the hung mass's force
**ramped up** until it passes the elastic limit —
l'équivalent Python de la table `PASAPAS` de Cast3M (section 9 de la
formation) : ``pyrucast.thermomechanics.step_by_step`` orchestre la boucle
over the load steps and, at each step, a **modified** Newton (elastic
stiffness, sped up by Anderson acceleration).
Replacing ``model.elasticity`` with ``Model.plasticity`` in
`formation/mecanique.py` is all it takes to get this script — the same call
``step_by_step`` gère la boucle non linéaire.
A pyrucast caveat, specific to this version of the library: plasticity
(`Model.plasticity`) does not consume the
matériau optionnelle `alpha` (dilatation thermique, Cast3M `EPTH`) — le
thermo-plastic coupling of Cast3M's section 9.2 (where `sigma_y` depends on
temperature) is therefore not covered here.
Lancement ::
maturin develop --release
python formation/plasticite.py
# To regenerate the book figure (book/src/formation/img/):
# PYRUCAST_FORMATION_IMG_DIR=book/src/formation/img python formation/plasticite.py
"""
import os
import tempfile
import pyrucast as pc
LONGUEUR, HAUTEUR = 0.30, 0.10 # m
RAYON_TROU = 0.025 # m
CENTRE_TROU = (0.75 * LONGUEUR, HAUTEUR / 2.0)
E, NU = 200e9, 0.3
# σy deliberately modest: this training's geometry and loading are not at the
# scale of a real steel — what matters is to bring out a plastic zone in a
# few steps, not the material's physical reality.
SIGMA_Y = 5e6
MASSE, G = 2500.0, 9.81
FACTEUR_CHARGE = 6.0 # multiplier on the hung mass, to pass σy
def construire_plaque_trouee():
"""Same geometry as `formation/mecanique.py`."""
coords = pc.Coords(2)
p1 = coords.add_node([0.0, 0.0])
p2 = coords.add_node([LONGUEUR, 0.0])
p3 = coords.add_node([LONGUEUR, HAUTEUR])
p4 = coords.add_node([0.0, HAUTEUR])
bas = pc.mesh.line(p1, p2, 10)
droit = pc.mesh.line(p2, p3, 4)
haut = pc.mesh.line(p3, p4, 10)
bord_gauche = pc.mesh.line(p4, p1, 4)
boucle_ext = pc.mesh.consolidate(bas | droit | haut | bord_gauche)
centre = coords.add_node(list(CENTRE_TROU))
trou = pc.mesh.circle(centre, [0.0, 0.0, 1.0], RAYON_TROU, 16)
# Outer loop CCW, hole clockwise (CW): the orientation
# `triangulate_surface` expects (the hole is inverted, `trou` stays usable below).
contour = boucle_ext | pc.mesh.invert(trou)
plaque = pc.mesh.triangulate_surface(contour, "TRI3", size=0.02)
y = pc.node_field.positions(trou, ["Y"])
noeuds_bas_trou = pc.mesh.select(y, lt=CENTRE_TROU[1])
arc_bas = pc.mesh.elements_on(trou, noeuds_bas_trou, strict=True)
return coords, plaque, bord_gauche, arc_bas
def main() -> None:
_coords, plaque, bord_gauche, arc_bas = construire_plaque_trouee()
fes = pc.FiniteElementSpace(plaque)
arc_fes = pc.FiniteElementSpace(arc_bas)
# ANCHOR: modele_plastique
encastrement = pc.mesh.to_poi1(bord_gauche)
multiplicateur = pc.mesh.translate(encastrement, [0.0, 0.0])
modele = pc.model.plasticity_perfect(fes, "plane_stress")
modele = modele | pc.model.dirichlet(modele, "u_x", encastrement, multiplicateur)
modele = modele | pc.model.dirichlet(modele, "u_y", encastrement, multiplicateur)
# ANCHOR_END: modele_plastique
# ANCHOR: chargement_evolution
pression = -FACTEUR_CHARGE * MASSE * G / (2.0 * 3.14159265 * RAYON_TROU)
modele = modele | pc.model.flux(arc_fes, modele, "f_y")
materiaux = pc.element_field.material_field(
modele, [("E", E), ("nu", NU), ("sigma_y", SIGMA_Y), ("phi_f_y", pression)]
)
effort_final = pc.node_field.external_forces(modele, materiaux)
charge = pc.Evolution(
[(0.0, effort_final * 0.0), (1.0, effort_final)], out_of_range="clamp"
)
# ANCHOR_END: chargement_evolution
# ANCHOR: pas_a_pas
# Free DOFs (outside the clamped end) to norm the Newton residual —
# without which the large support reactions mask the real convergence.
x = pc.node_field.positions(plaque, ["X"])
ddl_libres = pc.mesh.select(x, gt=1e-6)
data = {
"times": [0.0, 0.2, 0.4, 0.55, 0.7], # pseudo-temps ∈ [0, 1]
"model": modele,
"loads": charge,
"materials": materiaux,
"free_mesh": ddl_libres,
"max_newton": 200,
}
pc.thermomechanics.step_by_step(data)
# ANCHOR_END: pas_a_pas
print(f"{'t':>6} {'itérations':>11} {'anderson':>9} {'convergé':>9} {'p_max':>12}")
for r in data["results"]:
p_max = r["state"].max("p") if r["state"] is not None else 0.0
print(
f"{r['time']:>6.2f} {r['mech_iters']:>11} {r['mech_anderson']:>9} "
f"{r['converged']!s:>9} {p_max:>12.3e}"
)
dernier = data["results"][-1]
print(f"\nzone plastique développée : p_max = {dernier['state'].max('p'):.3e}")
out = os.environ.get("PYRUCAST_FORMATION_IMG_DIR", tempfile.gettempdir())
chemin = os.path.join(out, "plasticite.svg")
plaque.plot(
save=chemin, field=dernier["state"], component="p", cmap="viridis", smooth=0
)
print(f"Plastic zone (p) written to {chemin}")
if __name__ == "__main__":
main()
"""Formation débutant — 5. Contact (unilatéral, nœud-surface).
A classic patch test: two elastic blocks stacked along `y`, separated by an
initial gap `G0`. A pressure on the upper block closes the contact and
transmits a uniform stress across the interface — the pyrucast equivalent of
Cast3M's node-to-surface contact (section 10 of the training), driven here
straight by the active-set solver `solve_unilateral` rather than by
`step_by_step` (which cannot yet compose thermics, plasticity and contact in
a single table).
Lancement ::
maturin develop --release
python formation/contact.py
# To regenerate the book figure (book/src/formation/img/):
# PYRUCAST_FORMATION_IMG_DIR=book/src/formation/img python formation/contact.py
"""
import os
import tempfile
import pyrucast as pc
E = 100.0
S = 5.0 # pression appliquée
G0 = 0.01 # initial gap between the two blocks
N = 2 # N×N grid of QUA4 per block
def idx(i, j):
return j * (N + 1) + i
def bloc(coords: pc.Coords, y0: float):
"""Bloc `[0,1] × [y0, y0+1]`, grille N×N de QUA4 — mailleurs dédiés
(`line` for the bottom/top edges, `sweep` between them, as in
`formation/maillage.py`). Returns `(mesh, grille)`, `grille[idx(i,j)]`
étant le nœud `(i,j)` (`i` : abscisse, `j` : ordonnée)."""
bas = pc.mesh.line(coords.add_node([0.0, y0]), coords.add_node([1.0, y0]), N)
haut = pc.mesh.line(
coords.add_node([0.0, y0 + 1.0]), coords.add_node([1.0, y0 + 1.0]), N
)
mesh = pc.mesh.sweep(bas, haut, N)
grille = [None] * ((N + 1) * (N + 1))
for cy in range(N):
for cx in range(N):
cell = cy * N + cx
grille[idx(cx, cy)] = mesh.node(0, cell, 0)
grille[idx(cx + 1, cy)] = mesh.node(0, cell, 1)
grille[idx(cx + 1, cy + 1)] = mesh.node(0, cell, 2)
grille[idx(cx, cy + 1)] = mesh.node(0, cell, 3)
return mesh, grille
def clamp(target, nodes, var):
imposed = pc.mesh.poi1_from_nodes(nodes)
multiplier = pc.mesh.barycenter(imposed)
return pc.model.dirichlet(target, var, imposed, multiplier)
def bord_horizontal(mesh: pc.Mesh, y: float) -> pc.Mesh:
"""Extracts, among `mesh`'s border segments (`pyrucast.mesh.border`, the
Cast3M `CONTOUR` equivalent), those at ordinate `y` — an existing edge of
the mesh, not a line rebuilt beside it (`line` would make
nouveaux nœuds, disjoints de `mesh`)."""
frontiere = pc.mesh.border(mesh)
ordonnee = pc.node_field.positions(frontiere, ["Y"])
noeuds = pc.mesh.select(ordonnee, ge=y - 1e-9, le=y + 1e-9)
return pc.mesh.elements_on(frontiere, noeuds, strict=True)
def main() -> None:
# ANCHOR: geometrie_contact
coords = pc.Coords(2)
mesh_bas, bas = bloc(coords, 0.0)
mesh_haut, haut = bloc(coords, 1.0 + G0)
mesh = mesh_bas | mesh_haut
fes = pc.FiniteElementSpace(mesh)
# Master: top edge of the lower block (`contour` already orients the
# boundary counter-clockwise, so this edge naturally runs right to left —
# the associated normal points towards +y). Slave: nodes of the upper
# block's bottom edge.
maitre = bord_horizontal(mesh_bas, 1.0)
esclave = pc.mesh.poi1_from_nodes([haut[idx(i, 0)] for i in range(N + 1)])
elasticite = pc.model.elasticity(fes, "plane_stress")
contact = pc.model.contact(elasticite, esclave, maitre, ["u_x", "u_y"])
# ANCHOR_END: geometrie_contact
# ANCHOR: modele_contact
modele = pc.model.elasticity(fes, "plane_stress")
modele = modele | clamp(modele, bas + haut, "u_x")
modele = modele | clamp(modele, [bas[idx(i, 0)] for i in range(N + 1)], "u_y")
modele = modele | contact
# ANCHOR_END: modele_contact
# ANCHOR: chargement_contact
bord_haut = bord_horizontal(mesh_haut, 2.0 + G0)
bord_haut_fes = pc.FiniteElementSpace(bord_haut)
modele = modele | pc.model.flux(bord_haut_fes, modele, "f_y")
materiaux = pc.element_field.material_field(
modele, [("E", E), ("nu", 0.0), ("phi_f_y", -S)]
)
traction = pc.node_field.external_forces(modele, materiaux)
# `contact_gaps()` supplies the contact's right-hand side — the Cast3M
# equivalent of preparing the unilateral problem before RESO.
second_membre = traction | modele.contact_gaps()
# ANCHOR_END: chargement_contact
# ANCHOR: resolution_contact
K = pc.matrix.stiffness(modele, materiaux)
solution = pc.solver.solve_unilateral(K, modele, second_membre)
# ANCHOR_END: resolution_contact
print(f"Pression appliquée : {S}")
for j in range(N + 1):
uy_bas = solution.value(bas[idx(0, j)], "u_y")
uy_haut = solution.value(haut[idx(0, j)], "u_y")
print(f" y={j / N:.2f} : u_y(bas)={uy_bas:.6e} u_y(haut)={uy_haut:.6e}")
# Réactions de contact : Σ(−λᵢ) doit reconstituer l'effort appliqué S.
maillage_mult = contact.multiplier_mesh()
lambdas = [
solution.value(maillage_mult.node(0, r, 0), "lambda_contact")
for r in range(N + 1)
]
print(f"\nΣ(−λ) = {sum(-lam for lam in lambdas):.6f} (attendu {S})")
out = os.environ.get("PYRUCAST_FORMATION_IMG_DIR", tempfile.gettempdir())
chemin = os.path.join(out, "contact.svg")
maillage_mult.plot(
save=chemin, field=solution, component="lambda_contact", cmap="viridis"
)
print(f"Contact reaction (λ) written to {chemin}")
if __name__ == "__main__":
main()
Suite : Compléments — éléments structuraux et export des résultats.
Compléments
Éléments structuraux
Cast3M sélectionne le type d’élément dans MODE ('POUT', 'TIMO',
'DKT', 'COQ4'…) et ses caractéristiques géométriques dans MATE
('SECT', 'INRY', 'EPAI'…). pyrucast suit le même principe : un
opérateur model.<physique>(fes, ...) par famille d’éléments, des
composantes matériau nommées portant la géométrie de la section.
| pyrucast | Cast3M | primales / duales |
|---|---|---|
model.truss(fes) | MODE ... 'BARR' | u_x,u_y(,u_z) / f_x,f_y(,f_z) |
model.timoshenko(fes) | MODE ... 'TIMO' | w, theta / f_w, m_theta |
model.timoshenko(fes) | MODE ... 'POUT' | selon la dimension : w, 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}")
Non disponible dans pyrucast. Pas d’élément coque (Cast3M
DKT,COQ4,COQ2) — la mécanique 2D/3D de pyrucast reste un continuum (contraintes planes, déformations planes, 3D massif) ou des éléments structuraux filaires (barre, poutre). Pas de mode axisymétrique (Cast3MOPTI 'MODE' 'AXIS') ni de configuration purement 1D (OPTI 'DIME' 1) —Coordsest toujours 2D ou 3D cartésien.
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, l’équivalent
Cast3M MASS/LUMP/KSIG/KTAN.
Aller plus loin en 3D
Cette formation reste en 2D, mais rien n’empêche de reprendre la même plaque trouée en volume :
pyrucast.mesh.extrude(mesh, direction, n_couches)— balayage d’un maillageSEG2/TRI3/QUA4selon une direction, dans le même espace de coordonnées (Cast3MTRAN/VOLU 'TRAN') ;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é (Cast3MROTA/VOLU 'ROTA') ;pyrucast.mesh.sweep_solid(mesh_a, mesh_b, n_couches)— balayage entre deux profilsTRI3/QUA4non parallèles (Cast3MREGL+VOLU) ;pyrucast.mesh.triangulate_volume(enveloppe, taille)— remplissageTET4d’une enveloppeTRI3fermée par triangulation de Delaunay 3D (Cast3MVOLUpar remplissage).
Aucun de ces quatre n’est mis en œuvre dans les scripts de cette formation.
Éléments finis supportés
Catalogue complet : Éléments finis supportés — 14
types, de POI1 à HEX27, y compris les versions quadratiques
(pyrucast.mesh.to_quadratic, l’équivalent Cast3M CHAN 'TRI6' ...).
É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.
Non disponible dans pyrucast. Pas d’échange Nastran/Abaqus, pas de format CSV/Excel dédié pour les listes ou les tables (Cast3M
SORT 'EXCE'/LIRE 'CSV'), pas de format XDR de sauvegarde/restitution (Cast3MOPTI 'SAUV'/OPTI 'REST') — un script pyrucast reconstruit toujours son état depuis son code, il ne le sérialise pas sur disque. 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 : pas de procédures Gibiane à écrire
dans un dossier procedur/, pas de compilation de sources Esope. 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, qui
jouent le rôle des chapitres Cast3M sur les sources Esope.
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,
Display/Debug/dump, sérialisation, Definition of Done. - 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 — les objets derrière un
Handle, les guards, le compteur par nœud deCoords, et la mécanique de l’archive. - Parallélisme — rayon porté au-dessus des noyaux, zéro-copie, déterminisme, et ce qui reste séquentiel.
- Compilation et tests — installation détaillée, features
Cargo, génération du stub
.pyi, script « tout-en-un », dépannage. - Ajouter une physique — le coût en O(1)
fichier d’une nouvelle variante de
SubModel/SubModelKind. - Ajouter un élément fini — un nouveau
ElementType, son interpolation et sa quadrature. - Interrompre une fonction — le jeton
Cancel: 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 et les
│ │ # variantes à symétrie 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
├── thermomechanics.py # couche Python pure de haut niveau
├── 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…)
formation/ # scripts de la formation 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.
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
Affichage : Debug vs Display
Chaque objet du modèle implémente deux traits :
Debug— vue structurelle : utile pour le développement, exposée en Python via__repr__.Display— vue résumée orientée utilisateur EF, façon listing cast3m, exposée en Python via__str__.
Le binding PyO3 branche ces deux vues sur les dunder methods Python correspondantes.
Rust :
#[test]
fn debug_montre_la_structure_display_le_resume() {
let coords = Handle::new(Coords::new(2).unwrap());
let c = coords.read();
println!("{:?}", *c); // vue structurelle (Debug)
println!("{}", *c); // vue résumée (Display)
}
Python :
import pyrucast
c = pyrucast.Coords(dim=2)
c.add_node([0.0, 0.0])
print(repr(c)) # vue structurelle — __repr__
print(str(c)) # vue résumée cast3m — __str__
print(c) # same as str(c)
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;
#[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é. Debug(structure) +Display(résumé).- 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 (
__repr__/__str__). - 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.
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 de formation | formation/*.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.
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 | formation/<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/,formation/,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 la formation 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 |
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 cinq 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.
Deux 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.
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
pyrucast gère la mémoire à deux niveaux, et deux seulement :
- Les objets (
Coords,SubMesh,NodeField, …) vivent derrière unHandle<T>— une référence comptée munie de son propre verrou. Le dernier handle qui disparaît emporte l’objet. - Les nœuds, à l’intérieur d’une
Coords, portent leur propre compteur de références. Ils ne sont pas des objets : ce sont des indices dans un tableau, et c’est ce qui impose un second mécanisme.
Ce chapitre décrit les deux, et explique pourquoi le second ne se ramène pas au premier.
Les objets : Handle<T>
Ce que c’est
///
/// ```
/// # 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>>,
}
Une enveloppe d’un champ, et rien d’autre. Arc (Atomically Reference Counted) est la boîte partagée à tickets de propriété de la bibliothèque standard : la cloner ne copie pas le contenu, elle crée un second ticket sur la même boîte, libérée au dernier ticket rendu. RwLock est le verrou lecture/écriture qui arbitre les accès.
Trois propriétés en découlent, et ce sont les seules à retenir :
- Compté.
Clonepartage,Droprelâche. Quand le dernier handle disparaît, la valeur est détruite — sonDrops’exécute, donc les effets de bord (unSubMeshqui rend ses nœuds à laCoords) ont lieu exactement une fois. - Toujours valide. Un handle ne peut pas survivre à son objet : le détenir, c’est le maintenir en vie. Il n’y a pas de handle périmé, pas de génération à vérifier, et
read/writene peuvent pas échouer. - L’identité, c’est le pointeur.
same_objectrépond à « ces deux références désignent-elles le même objet ? » — c’est ce qui permet à l’union des agrégats d’ignorer une zone qu’elle possède déjà, et à un champ de reconnaître que son support est bien le maillage qu’on lui a passé.
#[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
}
Aucun remove() à appeler, aucune fonction de libération : la portée Rust suffit.
Les guards : lire et écrire en place
Avec un handle, on n’écrit pas handle.dim directement. On passe par un guard :
#[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
}
Le guard prouve qu’on détient le verrou. Il se comporte comme une référence vers la donnée (par Deref), et le verrou est relâché à sa destruction — c’est du RAII : impossible d’oublier de déverrouiller. Les règles du borrow-checker s’appliquent à travers lui, arbitrées à l’exécution par le RwLock : N lecteurs simultanés, ou 1 écrivain exclusif.
Un point compte pour les performances : les guards sont possédés ('static). Ils détiennent leur propre ticket sur l’objet, donc ils peuvent être renvoyés par une fonction, rangés dans une struct — c’est le mécanisme de FieldView, la vue zéro-copie des champs — et ils maintiennent l’objet en vie même si le handle dont ils viennent disparaît entre-temps. C’est ce qui permet aux opérateurs (gradient, solveur, visualisation) de lire les données en place pendant toute leur boucle, au lieu d’en faire des copies.
Concurrence : un verrou par objet
Il n’y a pas d’annuaire global, donc rien à sérialiser entre threads en dehors des objets eux-mêmes :
- deux threads qui manipulent des objets différents — même du même type — ne se gênent jamais ;
- plusieurs threads peuvent lire le même objet simultanément ;
- un écrivain est exclusif sur son objet, et seulement le sien.
C’est la granularité dont vit le parallélisme interne aux opérateurs : plusieurs threads lisent le même maillage pendant l’assemblage (voir Parallélisme).
Une seule règle d’usage, non vérifiée à la compilation : ne pas demander un second guard sur un objet dont on tient déjà un guard en écriture (ni write un objet qu’on est en train de lire dans le même thread) — le RwLock n’est pas réentrant. Les objets distincts se verrouillent librement, y compris de façon imbriquée.
Affichage : <SubMesh #7f3a2c>
Formater un handle n’ouvre pas l’objet :
<SubMesh #7f3a2c>
Le nombre est l’adresse de l’objet, tronquée. Il sert à vérifier à l’œil, dans une trace, que deux lignes parlent bien du même objet. Ce n’est pas une identité pérenne : une adresse est réutilisée une fois l’objet libéré, donc deux entrées portant le même repère dans un journal écrit au fil du temps peuvent désigner deux objets différents.
Le choix de ne rien lire est délibéré : un SubMesh porte une connectivité de plusieurs millions d’entrées, et un handle peut fort bien être formaté alors qu’un guard en écriture est tenu sur lui — le lire bloquerait.
Pourquoi un Handle plutôt qu’un Arc<RwLock<T>> nu ?
L’enveloppe ne coûte rien à l’exécution et rapporte trois choses :
- Des méthodes propres —
h.read()plutôt qu’un trait d’extension importé partout. - Une surface réduite — un
Arcnu exposeraittry_unwrap,get_mut,strong_count: de quoi contourner le verrou. - Un entonnoir de création unique —
Handle::new. Si l’énumération des objets vivants devient un jour souhaitable (un listing à la cast3m, une sauvegarde de session entière), elle coûte unVec<Weak<_>>inscrit dans ce seul constructeur, au lieu d’une chasse à travers tous les sites de création.
Ce qui a précédé
Il y a eu un store global : un registre par TypeId, un tableau de cases numérotées et générationnelles, une free-list, un compteur de références écrit à la main, et un délestage sur disque. Chaque pièce doublait quelque chose que l’Arc faisait déjà :
- le compteur maison suivait ce que suivait celui de l’
Arc; - la génération ne protégeait que les handles reconstruits depuis des octets, que seul le délestage produisait ;
- et ce délestage, lui, ne libérait rien : il écrivait sur disque puis oubliait la valeur au lieu de la détruire.
Ce que le registre offrait en plus — énumérer les objets vivants, leur donner un numéro stable — n’a jamais atteint un utilisateur. Il a donc été retiré ; l’entonnoir Handle::new garde la porte ouverte si le besoin se présente.
Les nœuds : un compteur dans la Coords
À l’intérieur d’une Coords, chaque nœud porte son propre compteur. Il est incrémenté quand un Node est cloné ou qu’un SubMesh référence le nœud dans une maille, décrémenté quand ils disparaissent ; gc() collecte les nœuds retombés à zéro. Le détail est au chapitre Coords.
Pourquoi ne pas leur donner un Handle comme aux objets ? Parce qu’un nœud n’est pas un objet : c’est un indice dans un tableau de connectivité. Un SubMesh de 100 k HEX20 en porte deux millions d’occurrences ; en faire autant de pointeurs comptés multiplierait la mémoire de la connectivité et détruirait sa localité. Le compteur reste donc un Vec<u32> parallèle au tableau de coordonnées, où le nœud n’occupe que quatre octets de comptage.
Pourquoi un compteur plutôt qu’un mark-and-sweep ?
L’alternative serait un GC mark-and-sweep : pas de compteur, mais à chaque gc(), parcourir tous les Node / SubMesh / NodeField vivants pour marquer les NodeId atteignables. La simplification est apparente — il faut d’abord savoir où sont les racines.
Un SubMesh ou un NodeField est atteignable depuis les handles que l’utilisateur tient. Mais un Node vit sur la pile Rust (ou dans le tas Python via PyO3), et rien ne l’énumère. Deux options seulement :
- Maintenir dans la
Coordsune liste séparée desNodevivants, mise à jour àClone/Drop. C’est le compteur actuel déguisé enHashSet<NodeId>— sans gain. - Changer le contrat :
Nodedevient une vue non protectrice, et seul un maillage peut maintenir un nœud vivant. C’est exactement le modèle cast3m (« un point isolé n’existe pas en dehors d’un MAILLAGE »). Légitime, mais c’est une rupture d’API plus large que la simplification cherchée.
Où est le coût réel du compteur ?
SubMesh::add_cell:N × increfsous un seul verrou — négligeable devant la création de la maille.Node::clone/drop: un verrou et une indexation deVec<u32>. Sensible seulement si on clone ou détruit beaucoup deNodeen boucle serrée, ce qui est rare en calcul EF — on construit le maillage, puis on n’y touche plus.- Complexité conceptuelle : la logique d’annulation dans
add_cellpèse plus sur la lisibilité que sur le temps de calcul.
Choix pyrucast : compteur pour l’instant. Si la complexité devient gênante, le levier le plus rentable n’est pas de remplacer le mécanisme mais de simplifier le contrat de Node (option 2, mode cast3m pur) — cela supprime à la fois le compteur et la logique d’annulation. À garder en tête si l’on observe que les Node isolés servent peu en pratique.
Sauvegarde sur disque
Le trait Portable (serde + bincode) fixe le contrat d’octets : un format binaire identique Linux ↔ Windows (voir Conventions).
Au-dessus, l’archive sauve un graphe d’objets et le relit en préservant le partage — deux champs portés par un support restent, après relecture, deux champs portés par un seul support. Le mode d’emploi est au chapitre Sauvegarde et relecture ; ce qui suit en est la mécanique, du point de vue mémoire.
Un handle devient un identifiant, le temps d’un fichier
Un handle est une adresse. Une adresse n’a aucun sens dans un autre processus, donc ce qui part sur le disque est un identifiant local au fichier. La table qui traduit l’un en l’autre n’existe que pendant un save ou un load : hors de là, sérialiser un handle est une erreur — jamais un octet écrit au hasard.
La découverte des dépendances est la sérialisation
Rien ne déclare ce qu’un objet référence. C’est Handle::serialize qui découvre :
déjà vu ? → écrire son identifiant, fini
sinon → réserver un identifiant
sérialiser l'objet pointé dans son propre enregistrement
(ce qui, récursivement, découvre ses propres dépendances)
déposer l'enregistrement
écrire l'identifiant
Ajouter un champ Handle à une structure en fait donc une arête, sans rien à tenir à jour ailleurs — c’est le point : une liste d’arêtes écrite à la main serait une liste qu’on oublie de compléter, et la sauvegarde serait silencieusement incomplète.
Les enregistrements sortent dans l’ordre des dépôts, donc toute dépendance précède ce qui la référence : la relecture est une simple boucle avant.
Le cycle est refusé, pas supposé absent
Le graphe écrit est acyclique. Le graphe vivant, lui, ne l’est pas : le compagnon POI1 mémoïsé d’un sous-maillage désigne un autre sous-maillage. L’acyclicité de l’écrit tient donc par conséquence — les caches ne sont pas écrits — et non par construction.
L’écrivain ne la suppose pas : rencontrer un identifiant réservé mais pas encore déposé est la signature d’un cycle, et l’erreur nomme l’objet au lieu de laisser la pile déborder.
Les compteurs, aux deux niveaux
Aucun n’est écrit. Ceux des objets se recomptent seuls : chaque référence que load fabrique compte pour une. Ceux des nœuds repartent de zéro — la Coords relue les remet à zéro, puis chaque sous-maillage relu ré-incrémente ce qu’il utilise, dans cet ordre que le post-ordre garantit.
D’où la conséquence énoncée au chapitre Sauvegarde et relecture : un nœud relu n’est protégé que par les objets présents dans le fichier.
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 | récent | Format du Python (ruff format), vérifié par check_format |
mdbook | ≥ 0.4 | Génération de cette documentation |
mdbook-mermaid | ≥ 0.14 | 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
Windows (PowerShell)
python -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install --upgrade pip maturin pytest ruff
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 formation 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-formation-figures.sh (régénère les SVG de la formation, 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 | ✓ | ✓ | ✓ | ✓ | ||||
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 --features extension-module,viz | l’extension Python s’installe | — | 20 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 formation/*.py | 80 s | ✓ | ✓ | ✓ | |||||
cargo doc --no-deps --lib (-D warnings) | la rustdoc n’a aucun lien cassé | — | 1 s | ✓ | ✓ | ✓ | |||||
python script/doc_lint.py | les cinq garde-fous du book et des exemples | book/src/**.md, la rustdoc, le module Python installé | 28 s | ✓ | ✓ | ✓ | |||||
mdbook build book | le book se rend | book/src/** | 1 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 | 64 s |
check_examples | exemples et formation | 80 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 de formation 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 lance script/doc_lint.py, quatre
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 |
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 mdbookou un binaire publié.The "mermaid" preprocessor exited unsuccessfully(ou graphes affichés en bloc de code brut) : installer le préprocesseur viacargo install mdbook-mermaid. Il doit être sur lePATHau moment demdbook build.
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, 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/.
#![allow(unused)]
fn main() {
crate::physics_operator! {
/// Truss / bar `Model` spanning **every** subspace of `fes` — 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 mult = mesh::barycenter(&impose).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))?;
/// let m = model::truss(&barres)?;
/// assert_eq!(m.primal_vars(), vec!["u_x".to_string(), "u_y".to_string()]);
/// # Ok::<(), pyrucast::PyrucastError>(())
/// ```
pub fn truss(fes) via SubModel::truss;
python: "`model.truss(fespace)` — truss / bar (axial-force) model spanning\n**every** subspace of `fespace` (SEG2 elements). DOFs are the vector\ndisplacement `u_x, u_y(, u_z)`; the orientation is taken from the node\ncoordinates. Material (`E`, `A`) is supplied at assembly time."
}
}
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 (fes, target, …). Il a besoin du modèle qu’il
charge ou refroidit : pour y vérifier que ses lignes sont assemblées, et, quand
ses noms de variables sont libres, pour 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_physics (couples primale/duale) 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 ; - les variantes à symétrie (
heat_conduction,fick,elasticity), dont la face Python replie la symétrie ensymmetry=None; 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 */ }
// 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>;
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()+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.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).
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::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 mult = mesh::barycenter(&impose).unwrap();
/// # let volume = SubModel::heat_conduction(zone.clone()).unwrap();
/// # let cible = pyrucast::ops::model::heat_conduction(&fes).unwrap();
/// # let appui = SubModel::dirichlet(&cible, "T", &impose, &mult, 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, la composante duale imposed_value où l’utilisateur écrira le
second membre g, 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é, indépendante de la méthode d’imposition :
la voie Lagrange (contributions()) en tire ses blocs C / Cᵀ — via le helper
partagé constraint_block_pair — et la voie par élimination
(ops::solver::eliminate) lit les mêmes relations. Ni l’une ni l’autre ne
re-parse le maillage-par-terme fourni par l’utilisateur. Une nouvelle contrainte
n’a donc à décrire ses relations qu’une fois.
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). VautNoneau premier pas (configuration de référence) ;material— les données matériau de la zone,Some(_)ssi la physique déclare unmaterial_fespace;dt— l’incrément de temps,Nonepour une loi indépendante du temps (une loi visqueuse erronera s’il manque).
Le noyau écrit dans out les composantes déclarées par
behavior_output_components() : l’état matériau en B — σ(B), VAR(B), et
éventuellement D_alg pour une physique qui alimente la tangente cohérente. La
sortie devient le prev du pas suivant.
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 les deux tableaux de
elements/index.md, et une ligne dans la table de
correspondance gmsh d’operateurs/maillage.md.
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é).