Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

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

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

Philosophie

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

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

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

Coords ── Node                          (NodeId u32 stable ; Node = accesseur RAII)
   │
   ├── 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 :

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

Premiers pas

Exemple minimal en Rust :

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

    let 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::…::Foo est exposée sous le même nom pyrucast.Foo (le wrapper PyO3 interne PyFoo est masqué) ;
  • une fonction libre ops::<module>::f est exposée dans le sous-module de même nom : pyrucast.<module>.f. Le module Rust porte le nom du conteneur produit (mesh, node_field, element_field, matrix, model, coords), ou de l’activité quand il ne produit aucun conteneur (measure, geom, export) ; solver est l’exception nommée. 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] de src/lib.rs (enregistrement des classes et fonctions) et le stub python/pyrucast/_pyrucast/__init__.pyi (signatures typées). Cette page en est un instantané, à régénérer à la main si l’API bouge.

Structures ↔ classes

Le nom de la classe Python est identique au nom de la structure Rust.

Module RustStructure RustClasse PythonChapitre
coordsCoordspyrucast.CoordsCoords
atoms::nodeNodepyrucast.NodeNœud
containers::meshSubMeshpyrucast.SubMesh (vue, via mesh[i])Maillage
containers::meshMeshpyrucast.MeshMaillage
atoms::cellCellpyrucast.CellMaillage
containers::finite_element_spaceSubFiniteElementSpacepyrucast.SubFiniteElementSpaceEspace EF
containers::finite_element_spaceFiniteElementSpacepyrucast.FiniteElementSpaceEspace EF
atoms::elementElementpyrucast.ElementEspace EF
containers::node_fieldSubNodeFieldpyrucast.SubNodeField (vue, via node_field[i])Champ aux nœuds
containers::node_fieldNodeFieldpyrucast.NodeFieldChamp aux nœuds
containers::element_fieldSubElementFieldpyrucast.SubElementField (vue, via element_field[i])Champ aux points de Gauss
containers::element_fieldElementFieldpyrucast.ElementFieldChamp aux points de Gauss
containers::matrixSubMatrixpyrucast.SubMatrix (vue, via matrix[i])Matrice creuse
containers::matrixMatrixpyrucast.MatrixMatrice creuse
containers::modelSubModelpyrucast.SubModel (vue, via model[i])Modèle physique
containers::modelModelpyrucast.ModelModèle physique
containers::evolutionSubEvolutionpyrucast.SubEvolution (constructible — voir ci-dessous)Évolution
containers::evolutionEvolutionpyrucast.EvolutionÉvolution

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>) -> Meshfrom_live_nodes(coords) -> Mesh
poi1_from_nodes(nodes: &[Node]) -> Meshpoi1_from_nodes(nodes) -> Mesh
line(a: &Node, b: &Node, n_elems: usize, element_type: ElementType) -> Meshline(a, b, n_elems, element_type="SEG2") -> Mesh
circle(center: &Node, normal: &[f64], radius: f64, n_elems: usize, element_type: ElementType) -> Meshcircle(center, normal, radius, n_elems, element_type="SEG2") -> Mesh
arc(node_a: &Node, center: &Node, node_b: &Node, n_elems: usize, element_type: ElementType) -> Mesharc(a, center, b, n_elems, element_type="SEG2") -> Mesh
sweep(mesh_a: &Mesh, mesh_b: &Mesh, n_layers: usize, element_type: ElementType) -> Meshsweep(mesh_a, mesh_b, n_layers, element_type="QUA4") -> Mesh
transfinite(side1: &Mesh, side2: &Mesh, side3: &Mesh, side4: &Mesh, element_type: ElementType) -> Meshtransfinite(side1, side2, side3, side4, element_type="QUA4") -> Mesh
sweep_solid(mesh_a: &Mesh, mesh_b: &Mesh, n_layers: usize) -> Meshsweep_solid(mesh_a, mesh_b, n_layers) -> Mesh
extrude(mesh: &Mesh, direction: &[f64], n_layers: usize) -> Meshextrude(mesh, direction, n_layers) -> Mesh
revolve(mesh: &Mesh, angle: f64, n_layers: usize, center: &[f64], axis: Option<&[f64]>) -> Meshrevolve(mesh, angle, n_layers, center, axis=None) -> Mesh
to_quadratic(mesh: &Mesh) -> Meshto_quadratic(mesh) -> Mesh
convert(mesh: &Mesh, element_type: ElementType) -> Meshconvert(mesh, element_type) -> Mesh
copy(mesh: &Mesh, new_nodes: bool) -> Meshcopy(mesh, new_nodes=True) -> Mesh
translate(mesh: &Mesh, vector: &[f64]) -> Meshtranslate(mesh, vector) -> Mesh
rotate(mesh: &Mesh, angle: f64, center: &[f64], axis: Option<&[f64]>) -> Meshrotate(mesh, angle, center, axis=None) -> Mesh
symmetry_point(mesh: &Mesh, center: &[f64]) -> Meshsymmetry_point(mesh, center) -> Mesh
symmetry_line(mesh: &Mesh, a: &[f64], b: &[f64]) -> Meshsymmetry_line(mesh, a, b) -> Mesh
symmetry_plane(mesh: &Mesh, a: &[f64], b: &[f64], c: &[f64]) -> Meshsymmetry_plane(mesh, a, b, c) -> Mesh
triangulate_surface(contour: &Mesh, et: ElementType, size: Option<f64>) -> Meshtriangulate_surface(contour, element_type, size=None) -> Mesh
pave_surface(contour: &Mesh, element_type: ElementType, size: Option<f64>, all_quad: bool) -> Meshpave_surface(contour, element_type, size=None, all_quad=False) -> Mesh
triangulate_volume(envelope: &Mesh, size: Option<f64>, allow_surface_nodes: bool) -> Meshtriangulate_volume(envelope, size=None, allow_surface_nodes=False) -> Mesh
pave_volume(envelope: &Mesh, layers: usize, thickness: Option<f64>, size: Option<f64>) -> Meshpave_volume(envelope, layers=1, thickness=None, size=None) -> Mesh
border(mesh: &Mesh, angle_deg: Option<f64>) -> Meshborder(mesh, angle_deg=None) -> Mesh
skin(mesh: &Mesh, angle_deg: Option<f64>) -> Meshskin(mesh, angle_deg=None) -> Mesh
orient(mesh: &Mesh) -> Meshorient(mesh) -> Mesh
invert(mesh: &Mesh) -> Meshinvert(mesh) -> Mesh
chain(mesh: &Mesh) -> Meshchain(mesh) -> Mesh
barycenter(mesh: &Mesh) -> Meshbarycenter(mesh) -> Mesh
to_poi1(mesh: &Mesh) -> Meshto_poi1(mesh) -> Mesh
elements_on(mesh: &Mesh, points: &Mesh, strict: bool) -> Meshelements_on(mesh, points, strict=True) -> Mesh
points_in_sphere(mesh: &Mesh, center: &[f64], radius: f64, tol: Option<f64>) -> Meshpoints_in_sphere(mesh, center, radius, tol=None) -> Mesh
points_on_sphere(mesh: &Mesh, center: &[f64], radius: f64, tol: Option<f64>) -> Meshpoints_on_sphere(mesh, center, radius, tol=None) -> Mesh
points_on_plane(mesh: &Mesh, origin: &[f64], normal: &[f64], tol: Option<f64>) -> Meshpoints_on_plane(mesh, origin, normal, tol=None) -> Mesh
points_below_plane(mesh: &Mesh, origin: &[f64], normal: &[f64], tol: Option<f64>) -> Meshpoints_below_plane(mesh, origin, normal, tol=None) -> Mesh
points_on_line(mesh: &Mesh, a: &[f64], b: &[f64], tol: Option<f64>) -> Meshpoints_on_line(mesh, a, b, tol=None) -> Mesh
points_in_cylinder(mesh: &Mesh, base: &[f64], top: &[f64], radius: f64, tol: Option<f64>) -> Meshpoints_in_cylinder(mesh, base, top, radius, tol=None) -> Mesh
points_on_cylinder(mesh: &Mesh, base: &[f64], top: &[f64], radius: f64, tol: Option<f64>) -> Meshpoints_on_cylinder(mesh, base, top, radius, tol=None) -> Mesh
points_in_cone(mesh: &Mesh, base: &[f64], top: &[f64], base_radius: f64, top_radius: f64, tol: Option<f64>) -> Meshpoints_in_cone(mesh, base, top, base_radius, top_radius=0.0, tol=None) -> Mesh
points_on_cone(mesh: &Mesh, base: &[f64], top: &[f64], base_radius: f64, top_radius: f64, tol: Option<f64>) -> Meshpoints_on_cone(mesh, base, top, base_radius, top_radius=0.0, tol=None) -> Mesh
points_in_torus(mesh: &Mesh, center: &[f64], axis: &[f64], major_radius: f64, minor_radius: f64, tol: Option<f64>) -> Meshpoints_in_torus(mesh, center, axis, major_radius, minor_radius, tol=None) -> Mesh
points_on_torus(mesh: &Mesh, center: &[f64], axis: &[f64], major_radius: f64, minor_radius: f64, tol: Option<f64>) -> Meshpoints_on_torus(mesh, center, axis, major_radius, minor_radius, tol=None) -> Mesh
merge_nodes(mesh: &Mesh, tol: f64, in_place: bool) -> Meshmerge_nodes(mesh, tol, in_place=False) -> Mesh
read_gmsh(coords: Handle<Coords>, path: &Path) -> Vec<(String, Mesh)>read_gmsh(coords, path) -> dict[str, Mesh]
read_gmsh_str(coords: Handle<Coords>, text: &str) -> Vec<(String, Mesh)>read_gmsh_str(coords, text) -> dict[str, Mesh]
from_arrays(coords: Handle<Coords>, node_tags: &[T], node_coords: &[f64], blocks: &[CellBlock<T>], node_values: &[NodeValues<T>], cell_values: &[CellValues<T>], order: NodeOrder) -> Importedfrom_arrays(coords, node_tags, node_coords, blocks, *, node_fields=(), cell_fields=(), order="pyrucast") -> (dict[str, Mesh], list[NodeField], list[ElementField])
element_type_from_gmsh(code: u32) -> ElementTypeelement_type_from_gmsh(code) -> str
gauss_to_external(et: ElementType, order: NodeOrder, ext_ref_nodes: &[f64]) -> (Vec<f64>, Vec<f64>)gauss_to_external(element_type, ref_nodes, order) -> (list, list)
match_gauss(et: ElementType, order: NodeOrder, ext_ref_nodes: &[f64], ext_xi: &[f64], ext_weights: &[f64]) -> Vec<usize>match_gauss(element_type, ref_nodes, xi, weights, order) -> list[int]
— (exige l’interpréteur)from_gmsh(coords, *, dim=-1, tag=-1, views=True) -> (dict[str, Mesh], dict)
— (exige l’interpréteur)from_medcoupling(coords, source, *, mesh_name=None) -> (dict[str, Mesh], dict)
consolidate(mesh: &Mesh) -> Meshconsolidate(mesh) -> Mesh
select_nodes(field: &NodeField, band: &Band, …) -> Mesh / select_cells(field: &ElementField, …) -> Meshselect(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>>) -> NodeFieldpositions(mesh, components=None) -> NodeField
divergence(field: &ElementField, prefix: &str) -> NodeFielddivergence(field, prefix) -> NodeField
restrict(field: &NodeField, mesh: &Mesh) -> NodeFieldrestrict(field, mesh) -> NodeField
restrict_like(field: &NodeField, target: &NodeField) -> NodeFieldrestrict_like(field, target) -> NodeField
merge(a: &NodeField, b: &NodeField) -> NodeFieldmerge(a, b) -> NodeField
consolidate(field: &NodeField) -> NodeFieldconsolidate(field) -> NodeField
mask(field: &NodeField, band: &Band, …) -> NodeFieldmask(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) -> NodeFieldinternal_forces(model, state) -> NodeField
external_forces(model: &Model, materials: &ElementField) -> NodeFieldexternal_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) -> ElementFieldgradient(field, fespace) -> ElementField
deformation(u: &NodeField, fespace: &FiniteElementSpace) -> ElementFielddeformation(u, fespace) -> ElementField
interp_to_gauss(field: &NodeField, fespace: &FiniteElementSpace) -> ElementFieldinterp_to_gauss(field, fespace) -> ElementField
thermal_strain(temperature: &ElementField, material: &ElementField, fespace: &FiniteElementSpace, t_ref: f64) -> ElementFieldthermal_strain(temperature, materials, fespace, t_ref) -> ElementField
shell_deformation(field: &NodeField, fespace: &FiniteElementSpace, model: ShellModel) -> ElementFieldshell_deformation(field, fespace, model) -> ElementField
beam_deformation(field: &NodeField, fespace: &FiniteElementSpace, material: &ElementField) -> ElementFieldbeam_deformation(field, fespace, material) -> ElementField (1-D, plan ou spatial selon le maillage ; le matériau est requis, l’interpolation dépendant de Φ)
consolidate(field: &ElementField) -> ElementFieldconsolidate(field) -> ElementField (fusionne les zones d’une même fespace)
mask(field: &ElementField, band: &Band, …) -> ElementFieldmask(field, ge=None, …) -> ElementField ; accepte aussi un SubElementField
sub_material_field(sub: &SubModel, pairs: &[(&str, f64)]) -> SubElementFieldsub_material_field(sub_model, components_and_values) -> SubElementField
material_field(model: &Model, pairs: &[(&str, f64)]) -> ElementFieldmaterial_field(model, components_and_values) -> ElementField
material_field_per_sub_model(model: &Model, per: &[&[(&str, f64)]]) -> ElementFieldmaterial_field_per_sub_model(model, components_and_values_per_sub_model) -> ElementField
behavior::integrate(model, deformation, prev, materials, dt) -> ElementFieldintegrate_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) -> f64integral(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) -> f64xty(x, y) -> float (dispatch par type ; produit scalaire global de deux champs)
SubField::xtx(&self) -> f64 / Field::xtx(&self) -> f64xtx(x) -> float (dispatch par type ; Σ v², norme au carré XTX)
SubField::xtx_components(&self, &[&str]) -> Result<f64>xtx(x, components=[…]) -> float (norme au carré restreinte à ces composantes)

ops::field — opérateurs génériques

Leur produit est un conteneur — toujours — mais pas un conteneur déterminé : il dépend de l’argument. La règle « un module par conteneur produit » ne désigne donc pas un module, et ils se rangent par domaine.

Rust (ops::field::…)Python (pyrucast.field.…)
psca<T: Pscal>(x: &T, y: &T) -> Tpsca(x, y) -> field (produit scalaire nœud par nœud, champ à une composante "psca"). Deux conteneurs en pairs et opération symétrique : fonction libre seule, pas de méthode
abs / sqrt / exp / log / log10 / cos / sin / tan / sinh / cosh / tanh <T: MapValues>(field: &T) -> Tmêmes noms pyrucast.field.…(field) — maths élément par élément (style numpy), un champ neuf du même type ; acceptent les quatre saveurs de champ (NodeField / SubNodeField / ElementField / SubElementField). Résultats non bornés : log de ≤ 0 → -inf/nan

La composition de zones passe par l’union (| Python / union Rust) ; l’arithmétique champ + scalaire et champ + champ par les opérateurs +, -, *, /, ** (cf. Opérateurs). Ni l’une ni l’autre n’est une fonction ops — merge(a, b) est juste un alias nommé de a | b.

ops::model — déclaration des physiques

Chaque opérateur rend un Model couvrant tout le support reçu (une zone par sous-espace) ; on compose les physiques hétérogènes avec |. Aucun n’a de forme méthode : le premier argument est le support que le modèle recouvre, pas un sujet qu’on transforme.

Rust (ops::model::…)Python (pyrucast.model.…)
heat_conduction(fes: &FiniteElementSpace) -> Modelheat_conduction(fespace, symmetry=None) -> Model
heat_conduction_with_symmetry(fes, symmetry: MaterialSymmetry) -> Modelidem, via symmetry=
fick(fes: &FiniteElementSpace, species: &str) -> Modelfick(fespace, species, symmetry=None) -> Model
fick_with_symmetry(fes, symmetry: MaterialSymmetry, species: &str) -> Modelidem, via symmetry=
radiation(fes: &FiniteElementSpace, target: &Model) -> Modelradiation(fespace, target) -> Model
flux(fes: &FiniteElementSpace, target: &Model, dual: String) -> Modelflux(fespace, target, dual) -> Model
boundary_transfer(fes, target: &Model, components: Vec<(String, String)>) -> Modelboundary_transfer(fespace, target, components) -> Model
interface_transfer(side_a, side_b, target: &Model, components, tol: f64) -> Modelinterface_transfer(side_a, side_b, target, components, tol=1e-9) -> Model — défaut interface_transfer::DEFAULT_TOL
truss(fes: &FiniteElementSpace) -> Modeltruss(fespace) -> Model
elasticity(fes, model: ElasticityModel) -> Modelelasticity(fespace, model, symmetry=None) -> Model
elasticity_with_symmetry(fes, model, symmetry: MaterialSymmetry) -> Modelidem, via symmetry=
plasticity_perfect(fes, model: ElasticityModel) -> Modelplasticity_perfect(fespace, model) -> Model
plasticity_with_law(fes, model, law: PlasticLaw) -> Modelune 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) -> Modelmazars(fespace, model) -> Model
damage_with_law(fes, model, law: DamageLaw) -> Modelune fonction par loi : damage_tc, damage_sic_sic — (fespace, model) -> Model
bernoulli(fes: &FiniteElementSpace) -> Modelbernoulli(fespace) -> Model
timoshenko(fes: &FiniteElementSpace) -> Modeltimoshenko(fespace) -> Model
shell(fes, model: ShellModel) -> Modelshell(fespace, model) -> Model
dirichlet(target: &Model, variable: &str, imposed_mesh, multiplier_mesh, sense: RelationSense) -> Modeldirichlet(target, variable, imposed_mesh, multiplier_mesh, sense=None) -> Model
mpc(terms: Vec<MpcTerm>, multiplier_mesh, sense) -> Modelmpc(target, terms, multiplier_mesh, sense=None) -> Model
embedded(target: &Model, immersed, host, variables: Vec<String>, tol) -> Modelembedded(target, immersed, host, variables, tol=None) -> Model
contact(target: &Model, slave, master, variables: Vec<String>) -> Modelcontact(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) -> Matrixstiffness(model, materials) -> Matrix
mass(model: &Model, materials: &ElementField) -> Matrixmass(model, materials) -> Matrix
lump(m: &Matrix) -> Matrixlump(matrix) -> Matrix
geometric(model: &Model, materials: &ElementField, stress: &ElementField) -> Matrixgeometric(model, materials, stress) -> Matrix
tangent(model, materials, deformation, prev: Option<&ElementField>, dt: Option<f64>) -> Matrixtangent(model, materials, deformation, prev=None, dt=None) -> Matrix

ops::solver — résolution

Rust (ops::solver::…)Python (pyrucast.solver.…)
lu::solve(matrix: &Matrix, rhs: &NodeField) -> NodeFieldsolve(matrix, rhs, method="lu", cache=True, verbosity="silent") -> NodeField
eliminate::solve(model: &Model, matrix: &Matrix, rhs: &NodeField) -> NodeFieldsolve_eliminate(matrix, model, rhs, method="lu", cache=True, verbosity="silent") -> NodeField
unilateral::solve(model: &Model, matrix: &Matrix, rhs: &NodeField) -> NodeFieldsolve_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) -> Exportedto_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() -> SpillStatsspill_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) -> Objectsload(path) -> dict

À l’écriture les types sont connus du compilateur, d’où la tranche de paires ; à la relecture non, d’où la table nommée dont on tire chaque objet avec son type attendu (objets.mesh("clef")?). Voir Sauvegarde et relecture.

ops::geom héberge locate_points (mapping iso-paramétrique inverse, sous le baignage) et project_points (projection au point le plus proche sur une surface, sous le contact) ; ces deux primitives sont internes (API Rust), pas encore exposées en Python — la seule dérogation de module entier 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éthode mesh.nearest_node([x, y]), des deux côtés.

Opérateurs (dunders ↔ traits Rust)

Toutes les classes implémentent __repr__ (← Debug, vue structurelle) et __str__ (← Display, vue résumée façon cast3m) — voir Conventions. Les autres opérateurs, classe par classe :

Arithmétique sur les champs

L’arithmétique (f + s, f + g, …) existe au niveau zone et au niveau agrégat ; les opérations par composante et entre champs sont portées par les traits SubField / Field. Les dunders +, -, *, /, ** dispatchent selon l’opérande droite : un float déclenche l’arithmétique scalaire, un champ du même type l’arithmétique champ + champ (valeur à valeur).

ClasseOpérateurs / méthodes PythonSémantiqueBacking Rust
SubNodeField / SubElementFieldf + s, f - s, f * s, f / s, f ** sbroadcast scalaire, nouveau champAdd/Sub/Mul/Div<f64>, map_all
SubNodeField / SubElementFieldf + g, f - g, f * g, f / g, f ** gchamp + champ par composante, union/passthrough (même support), nouveau champSubField::merge_components
NodeField / ElementFieldf + s, f - s, f * s, f / s, f ** sbroadcast scalaire sur toutes les zonesField::combine_scalar
NodeField / ElementFieldf + g, f - g, f * g, f / g, f ** gchamp + champ par (support, composante), union/passthroughField::merge_field
NodeField / ElementFieldf + sub, f - sub, … (sub = sous-champ)maj ciblée de la (des) zone(s) de même support (union/passthrough)Field::merge_subfield
zone & agrégatadd_to_component(c, s), sub_/mul_/div_to_componentscalaire sur une composante, en placeSubField/Field::map_component
zoneset_uniform(c, v)force une composante à vSubField::set_uniform

f + s / f + g renvoie un nouveau champ ; += n’est pas surchargé. La composition de zones n’est pas sur + : c’est l’union | (union en Rust, cf. ci-dessous). L’opérateur + est entièrement réservé à l’arithmétique de champ — scalaire (f + 1.0) et champ + champ valeur à valeur (f + g via merge_components/merge_field) ; p. ex. deux champs constants valant 1 s’additionnent en un champ constant valant 2. Pour fusionner des zones avec vérification (et non additionner) : merge(a, b) ≡ a | b.

Algèbre de Matrix / SubMatrix

Matrix.__mul__ dispatche selon l’opérande droite, comme l’arithmétique de champ ci-dessus : un NodeField déclenche le produit matrice-vecteur (mul_field), un float la mise à l’échelle paresseuse du facteur (voir Matrice creuse). / n’existe que pour le facteur (Matrix n’a pas de division matrice-vecteur), et refuse un diviseur nul (ZeroDivisionError) ou non fini (ValueError). Comme pour les champs, tous ces opérateurs renvoient une nouvelle Matrix — jamais de mutation en place.

+ et - additionnent deux opérateurs ; ils ne sont pas la composition (|, ci-dessous), qui écarte un bloc dont elle tient déjà l’emplacement : k | k vaut k, k + k vaut 2k.

ClasseOpérateurs / méthodes PythonSémantiqueBacking Rust
Matrixk * fieldproduit matrice-vecteur A·x, NodeField neufMatrix::mul_field, Mul<&NodeField>
Matrixk * s, s * k, k / s (s: float)facteur scalaire, blocs clonés dans de nouveaux slots (aucune valeur réécrite), CSR assemblée mise à l’échelle avec, k inchangéeMul/Div<f64> for &Matrix, Mul<Matrix> for f64
Matrix-kfacteur nié, sucre pour k * -1.0Neg for &Matrix
Matrix, SubMatrixa + b, a - bsomme : blocs des deux opérandes, partagés, non dédoublonnés ; résultat non assembléAdd/Sub (toutes combinaisons Matrix/SubMatrix)
SubMatrixb * s, s * b, b / s, -bbloc neuf au facteur ajusté (aucune valeur réécrite)Mul/Div<f64>/Neg for &SubMatrix
SubMatrix.factor (lecture seule)facteur courant du bloc (1.0 par défaut)SubMatrix::factor

Indexation par clé

ClasseOpérateurs PythonCléBacking Rust
SubNodeFieldf[nid, "c"], f[nid, "c"] = v(NodeId, composante)Index/IndexMut<(NodeId, &str)>
SubElementFieldf[cell, g, "c"], f[cell, g, "c"] = v(maille, point de Gauss, composante)méthodes value / set_value (pas de trait Index)

Protocole séquence — len(x), x[i], for _ in x

Classelen(x)x[i] →Backing Rust
Cellnombre de nœudsNodeméthodes
SubMeshnombre de maillesCellméthodes
Meshnombre de sous-maillagesSubMeshAggregate (macro)
SubFiniteElementSpacenombre d’élémentsElementméthodes
FiniteElementSpacenombre de sous-espacesSubFiniteElementSpaceAggregate (macro)
ElementFieldnombre de sous-champsSubElementFieldAggregate (macro)
Modelnombre de sous-modèlesSubModelAggregate (macro)
Matrixnombre de sous-matrices— (pas de [i])Aggregate (macro pour len)
SubMatrixnombre d’entrées—méthode entry_count
Evolutionnombre de sous-évolutionsSubEvolutionAggregate (macro)
SubEvolutionnombre d’échantillons tabulés—méthode __len__

Union | (composition d’agrégats)

La composition d’agrégats est l’union : côté Python elle s’écrit | (comme set | set), côté Rust ce sont les méthodes nommées union / union_sub / union_sub_first / union_subs (renvoient Result<…>). Les sous-objets sont partagés (refcount), jamais copiés ; les contraintes de domaine (même Coords pour Mesh, etc.) restent vérifiées.

Sémantique d’union (uniforme pour tous les agrégats) :

  1. Déduplication par handle : un sous-objet dont le Handle désigne un objet déjà présent (cf. Handle::same_object) n’est pas ajouté deux fois.
  2. Finalisation (Aggregate::finalize) : par défaut un no-op ; les champs la surchargent pour fusionner les zones partageant un même support (voir plus bas).
PythonRustRésultatSémantique
agrégat | agrégata.union(&b)agrégatunion dédupliquée, ordre de 1ʳᵉ apparition
agrégat | suba.union_sub(&h)agrégatajoute un sous-objet en queue (ignoré si déjà présent)
sub | agrégata.union_sub_first(&h)agrégatla même union, sous-objet en tête (via __ror__)
sub | subT::union_subs(&a, &b)agrégatunion des deux sous-objets
node | nodea.union(&b)Meshmaillage POI1 unitaire sur les deux nœuds
mesh | nodem.union_node(&n)Meshajoute un point (erreur si Mesh non unitaire POI1)

Vaut pour les sept agrégats (Mesh, FiniteElementSpace, Model, Matrix, NodeField, ElementField, Evolution) plus Node.

Finalisation des champs (fusion par support)

Après l’union par handle, NodeField et ElementField fusionnent les sous-champs définis sur le même support (même Handle<SubMesh> pour NodeField, même Handle<SubFiniteElementSpace> pour ElementField) :

  • le sous-champ fusionné porte l’union des composantes ;
  • une composante définie par plusieurs sous-champs doit y avoir la même valeur partout (comparaison exacte), sinon | lève une erreur ;
  • pour NodeField, une vérification inter-supports finale impose qu’un nœud partagé par des zones de supports différents s’accorde sur toute composante commune.

Ces opérations sont aussi exposées en Rust : ops::node_field::consolidate (NodeField) et ops::element_field::consolidate (ElementField).

+ est réservé à l’arithmétique de champ

L’opérateur + (et -, *, /) 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 : pyo3 y est une dépendance optionnelle, donc un build par défaut ne tire ni pyo3 ni libpython. 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) ou python3-devel (Fedora/RHEL). pyo3 en 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 par maturin en 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 :

  1. Libération automatique. Clone partage, Drop relâche. Quand le dernier handle d’un objet disparaît, l’objet est détruit — aucune fonction remove() à appeler.
  2. 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 / write ne peuvent pas échouer.
  3. L’identité, c’est le pointeur. same_object dit 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 (un Coords, un SubMesh…) 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 manuel Coords::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) :

ZoneAgrégat
SubMeshMesh
SubFiniteElementSpaceFiniteElementSpace
SubNodeFieldNodeField
SubElementFieldElementField
SubModelModel
SubMatrixMatrix
SubEvolutionEvolution

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__ (Rust Display) — résumé une ligne, façon listing cast3m ;
  • __repr__ (Rust Debug) — vue structurelle bornée, pour le développement ;
  • dump() — contenu intégral (valeurs, topologie) imprimé sur la sortie standard, au-delà de ce que repr montre.

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érateurméthoderend
triangulate_surfaceDelaunay contraint + raffinement de Ruppertdes TRI3
pave_surfacefront avançant, en rangées depuis le borddes QUA4, quelques TRI3
grid_surfacecœur en grille + bande frontale au borddes QUA4, quelques TRI3
grid_surface2idem, lignes prises une par nœud du contourdes 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.

formetriangulatepavegridgrid2
rectangle 1 × 0,63mailles286986060
pire0,4250,4650,9990,999
plaque, marche sur la grillemailles3941388080
pire0,4890,2901,0001,000

Les deux mailleurs en grille rendent le maillage qu’on dessinerait à la main : toutes les mailles sont des rectangles et il n’y a pas un seul triangle. Le paveur, lui, rend une pelure d’oignon 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.

formetriangulatepavegridgrid2
plaque à marche (0,53 ; 0,61)mailles3661368680
pire0,4810,1870,4050,963
L à cotes quelconquesmailles3081177670
pire0,4590,4460,4370,979
L étiré à 1,02mailles3191178074
pire0,4770,5510,3510,606
L étiré à 1,10mailles3361228074
pire0,4740,4460,4210,963

grid_surface pose une ligne sur la coordonnée où repose chaque côté aligné, puis découpe entre deux lignes d’après le côté qui les enjambe : toutes ses lignes sont droites. grid_surface2 donne à chaque nœud du contour la ligne qui le traverse et laisse ses rangées plier pour aller chercher le contour — une même rangée peut alors rejoindre deux parois qui se font face à deux hauteurs différentes, ce qu’aucune droite ne sait faire.

Le L étiré à 1,02 est le seul de la série que grid_surface2 ne règle pas : les deux côtés y posent un nombre de nœuds différent sur la même portée, dix contre onze, et aucune disposition de lignes n’invente la rangée manquante. Le remède est dans le contour, pas dans le mailleur — voir la règle de discrétisation.

Un profil à sept angles rentrants

Le profil crénelé est décliné en deux versions dont seule la base change : coupée sous chaque barre, ou d’un seul tenant. C’est le meilleur révélateur de la sensibilité d’un mailleur à la discrétisation du contour.

formetriangulatepavegridgrid2
base coupéemailles2 050860474456
pire0,4120,3660,3820,916
base d’un seul tenantmailles2 089863486456
pire0,4190,3370,3230,651

grid_surface2 rend exactement le même nombre de mailles dans les deux cas. Les trois autres paient la base d’un seul tenant. C’est la propriété la plus utile de ce mailleur : il pardonne une discrétisation de contour que les autres font payer.

Une forme oblique ou courbe

Ici le classement s’inverse, et c’est le seul endroit où il le fait.

formetriangulatepavegridgrid2
maisonmailles1 723620460450
pire0,4840,4910,4200,548
carré arrondimailles1 866668424409
pire0,4740,3600,3400,371
cercle R = 1mailles6 1142 3391 2362 044
pire0,4240,0310,3660,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_surface si la qualité du pire élément commande, grid_surface s’il faut des quadrangles ; jamais grid_surface2, qui y perd son cœur ;
  • 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_surface accepte all_quad=True et 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 × 20600 mailles, pire 0,541400, 1,000400, 1,000
L374, 0,315287, 0,449290, 0,564
profil crénelé859, 0,240620, 0,546554, 0,441
bande étroite 40 × 3581, 0,650557, 0,662559, 0,564
cercle R = 10569, 0,105633, 0,257639, 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é entre NodeField et ElementField : 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, le Coords reste en mémoire ;
  • tant qu’au moins un Node (ou un objet aval comme Mesh / Field via incref/decref) référence un NodeId, 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èleAvantagesLimites
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.

  • Clone incrémente le refcount du nœud dans le Coords ;
  • Drop le 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 le Node (refcount = 1). Pour obtenir un Node supplé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 un pyrucast.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 son Coords ;
  • position() — ses coordonnées dans la configuration active ;
  • set_position([x, y, …]) — réécrit ses coordonnées (dans la configuration active) ;
  • coords() — le Coords auquel il appartient ; filet de secours quand la poignée a été lâchée côté Python, comme Mesh.coords() ;
  • l’union node | node → un Mesh POI1 unitaire sur les deux nœuds (la même union | que les agrégats, cf. Agrégat) ; et mesh | node ajoute un point à un Mesh POI1 unitaire ;
  • les vues repr / str et dump().
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érationSens
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 | otherunion (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 (un Sub…) ; le slicing renvoie un agrégat du même type que agg, dont les zones sont des handles partagés (pas de copie). Le slicing s’appuie sur subset cô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 un TypeError (« SubMesh object is not an instance of Mesh »). 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érer unit() — 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 :

  1. Déduplication par handle. Une zone dont le Handle dé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.
  2. Partage, pas copie. Les zones retenues sont partagées (refcount), jamais dupliquées en mémoire.
  3. Contraintes de domaine. Les invariants (même Coords pour un Mesh, etc.) restent vérifiés au moment de l’ajout (check_push).
  4. 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 :

PythonRustRésultat
agg | agga.union(&b)union dédupliquée des deux listes
agg | suba.union_sub(&h)ajoute une zone en queue (ignorée si déjà présente)
sub | agga.union_sub_first(&h)la même union, la zone en tête
sub | subT::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 sont agg.add_sub(sub) (une zone) et agg.add_subs(other) (toutes les zones d’un autre agrégat, dans l’ordre). Elles vérifient check_push mais ne dédupliquent pas et n’appellent pas finalize : c’est de la concaténation. L’API d’usage pour composer reste | (cf. CONVENTIONS.md).

Plus, pour les nœuds (cf. Nœud) :

PythonRésultat
node | nodeMesh POI1 unitaire sur les deux nœuds
mesh | nodeajoute 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ême ElementType. Stocke la connectivité à plat (un Vec<NodeId> de longueur cell_count × nodes_per_cell).
  • Mesh : agrège plusieurs SubMesh liés à la même Coords.

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).

VarianteNœudsDim. topo.Cas usuel
POI110liste de nœuds
SEG221segment linéaire
TRI332triangle linéaire
QUA442quadrangle linéaire
TET443tétraèdre linéaire
PYRA553pyramide linéaire (raccord hexaèdre ↔ tétraèdre)
PENTA663prisme linéaire (extrusion d’un TRI3)
HEX883hexaèdre linéaire
SEG331segment quadratique
TRI662triangle quadratique
QUA882quadrangle quadratique (sérendipité)
QUA992quadrangle biquadratique (Lagrange complet, nœud central)
TET10103tétraèdre quadratique
PENTA15153prisme quadratique (sérendipité)
HEX20203hexaèdre quadratique (sérendipité)
HEX27273hexaèdre tri-quadratique (Lagrange complet, centres de face + 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
└── ...                               └── ...
  • SubFiniteElementSpace détient un Handle<SubMesh>, une Interpolation et une QuadratureRule. 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).
  • FiniteElementSpace détient un Handle<Mesh> figé et un Vec<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.

ElementTypeRepè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) :

ElementTypeParentNœuds de milieu d’arête (dans l’ordre), arête \( (a,b) \)
SEG3SEG2nœud 2 sur \( (0,1) \) (\( \xi = 0 \))
TRI6TRI33 sur \( (0,1), (1,2), (2,0) \)
QUA8QUA44 sur \( (0,1), (1,2), (2,3), (3,0) \)
QUA9QUA44 arêtes comme QUA8, puis un nœud central 8 en \( (0,0) \)
TET10TET46 sur \( (0,1), (1,2), (2,0), (0,3), (1,3), (2,3) \)
PENTA15PENTA69 : bas \( (0,1),(1,2),(2,0) \), haut \( (3,4),(4,5),(5,3) \), verticales \( (0,3),(1,4),(2,5) \)
HEX20HEX812 : bas \( (0,1),(1,2),(2,3),(3,0) \), haut \( (4,5),(5,6),(6,7),(7,4) \), verticales \( (0,4),(1,5),(2,6),(3,7) \)
HEX27HEX812 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 :

baselongueurrôle
géométrique (n_at_g)nodes_per_cellle jacobien \( \partial x/\partial\xi \)
de champ (field_n_at_g)shape_countl’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 :

espacegéométriechamp
LAGRANGE1 / LAGRANGE2Lagrangela même
HERMITE3Lagrange-1Hermite, 4 fonctions
MODEL_EMBEDDEDLagrangeabsente, 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érateurce qu’il interpole
models::fluxla fonction test d’une charge répartie
element_field::deformationu_r, pour la déformation orthoradiale
element_field::interp_to_gaussnodal → points de Gauss
measure::integralle 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ègleExactitude polynomiale
SEG22Gauss-Legendre sur \([-1, +1]\) : \( \xi_g = \pm 1/\sqrt{3} \), \( w_g = 1 \)\( \deg \le 3 \)
TRI33Hammer 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 \)
QUA44Produit tensoriel 2×2 de Gauss-Legendre : \( \xi_g = (\pm 1/\sqrt{3}, \pm 1/\sqrt{3}) \), \( w_g = 1 \)\( \deg \le 3 \) par direction
TET44Hammer : \( \alpha = \tfrac{5 - \sqrt{5}}{20} \), \( \beta = \tfrac{5 + 3\sqrt{5}}{20} \), points permutations, \( w_g = 1/24 \)\( \deg \le 2 \)
PYRA58Produit conique : 2×2 Gauss-Legendre sur la section carrée × Gauss-Jacobi 2 points en \( \zeta \) (poids \( (1-\zeta)^2 \), nœuds \( \tfrac13 \mp \tfrac{\sqrt{10}}{15} \))\( \deg \le 2 \)
PENTA66Produit 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 \)
HEX88Produit tensoriel 2×2×2 de Gauss-Legendre : \( \xi_g = (\pm 1/\sqrt{3})^3 \), \( w_g = 1 \)\( \deg \le 3 \) par direction
SEG33Gauss-Legendre 3 points\( \deg \le 5 \)
TRI66Règle symétrique degré 4 (Dunavant)\( \deg \le 4 \)
QUA89Produit tensoriel 3×3\( \deg \le 5 \) par direction
QUA99Produit tensoriel 3×3\( \deg \le 5 \) par direction
TET1011Règle de Keast degré 4 (un poids négatif)\( \deg \le 4 \)
PENTA1518Produit tensoriel TRI6 × Gauss 3 points sur \( \zeta \)\( \deg \le 4 \) / \( \le 5 \) en \( \zeta \)
HEX2027Produit tensoriel 3×3×3\( \deg \le 5 \) par direction
HEX2727Produit 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
SEG211, 2, 3
TRI322, 3
QUA422, 3
TET433
PYRA533
PENTA633
HEX833
SEG311, 2, 3
TRI622, 3
QUA822, 3
QUA922, 3
TET1033
PENTA1533
HEX2033
HEX2733

\( 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 :

GrandeurVariant 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 celluleouicalculé à la volée
\(J(\xi_g) \), \( \partial N_i / \partial x_a(\xi_g) \)

Ce choix donne deux propriétés importantes :

  1. Empreinte mémoire indépendante du nombre de cellules. Un SubFiniteElementSpace ne 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.
  2. Robustesse au déplacement. Réécrire les coordonnées dans la Coords (par exemple via Coords::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 exemple TRI3 + Lagrange2 tant que Lagrange2 n’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 SubFiniteElementSpace se 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 quadratiques SEG3, TRI6, QUA8, QUA9, TET10, PENTA15, HEX20, HEX27), Hermite3 (C¹, sur SEG2 seul) et ModelEmbedded (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 avec interpolation="LAGRANGE2" (le constructeur par défaut LAGRANGE1 refuse un type quadratique, et inversement). HERMITE3, lui, se pose sur un SEG2 sans 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 par ElementType) et QuadratureRule::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.
  • POI1 n’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 du FiniteElementSpace.
  • 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 tout Aggregate dont la zone est un SubField.

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 f64 dans 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éfinissent c (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 + s ne mute pas f. (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 par Add/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) ou merge_components(other, |a, b| a.powf(b)) (binaire). La forme ternaire pow(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 devient op(a, b) ; une composante d’un seul côté passe telle quelle (passthrough brut, pour tous les opérateurs — donc a - b sur une composante propre à b vaut b, 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, via SubField::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 maillage m ⇒ même support POI1 canonique, mis en cache), ou restrict_like(b, a) pour retomber sur celui de a (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é)

NiveauMéthodeEffet
zone & agrégatcomponents()composantes (union au niveau agrégat)
zone & agrégatmin(c) / max(c)extrema d’une composante — sans argument, de tout le champ
zoneset_uniform(c, v)force c à v
zone & agrégatf + s, f - s, f * s, f / sscalaire, nouveau champ
zone & agrégatf ** s, f ** gpuissance élément par élément (Python ** ; Rust : merge_components)
zone & agrégatadd_to_component(c, s) …scalaire sur une composante, en place
zone & agrégatf + g, f - g, f * g, f / gopérateurs binaires : union par (support, composante), passthrough brut
zonemerge_components(other, op)primitive des opérateurs de zone : union par composante, passthrough brut
zonecheck_same_components(other)garde-fou : erreur si support/composantes divergent (à appeler avant merge_components quand un écart est un bug)
agrégatmerge_field(other, op)primitive des opérateurs d’agrégat : union par (support, composante)
agrégatmerge_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 :

Et l’union | ? L’union compose des zones (structure), l’arithmétique combine des valeurs. Pour un ElementField, 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 un NodeField, 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 par node_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 de SubNodeField, 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 lot field.values(nœuds, comp) lit une liste de valeurs dans le même ordre : nœuds est une liste de nœuds, un SubMesh POI1, ou un Mesh POI1 (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 comme ElementField ;
  • 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ême SubMesh (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érationParticularité 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’un SubFiniteElementSpace ;
  • ElementField — l’agrégat : une liste de SubElementField, 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_count flottants contigus, exposés par point_values(cell, g) ;
  • balayer tous les points de Gauss d’une cellule pour une composante donnée — gauss_count flottants 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 :

PhysiquePrimales (cols)Duales (rows)
HeatConductionT (température)q (flux de chaleur)
BoundaryTransfer (film)T (partagée avec HeatConduction)q (partagée)
Truss / LinearElasticityu_x, u_y, …f_x, f_y, …
Dirichlet { imposed_variable: "T" }lambda_Timposed_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 :

  1. lit model.dual_vars() pour connaître les noms de composantes du vecteur force ;
  2. construit un NodeField avec ces composantes (forces de Neumann, sources de chaleur, valeurs imposées de Dirichlet aux nœuds-multiplicateurs, …) ;
  3. compose plusieurs sources avec | (union des zones, dédupliquée et fusionnée par support) — le nommé merge en est l’alias ;
  4. passe Matrix + NodeField au solveur.

Cette séparation a deux mérites :

  • les chargements sont des données utilisateur, faciles à composer ;
  • le Model reste 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.…)PrimalesDualesMatériauChapitre
heat_conduction(fes)TqkThermique
heat_conduction_with_symmetry(fes, sym)Tqk_1… / k_11… + repèreConduction orientée
boundary_transfer(fes, cible, comps)libreslibresh_<primale>, a_ext_<primale>Échanges
radiation(fes, cible)Tqemis, T_inf (+ sigma facultatif)Rayonnement
fick(fes, espèce)c_<espèce>j_<espèce>D_<espèce> ; poro facultatifDiffusion
fick_with_symmetry(fes, sym, espèce)c_<espèce>j_<espèce>D_1_<espèce>… + repère ; poro facultatifDiffusion
interface_transfer(a, b, cible, comps, tol)libreslibresh_<primale>Échanges
truss(fes)u_x, u_y(, u_z)f_x, f_y(, f_z)E, ABarre
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èreOrthotropie
plasticity_perfect(fes, model)u_x, u_y(, u_z)f_x, f_y(, f_z)E, nu, sigma_yPlasticité
plasticity_with_law(fes, model, law)idemidemselon la loiLois d’écoulement, Fluage
bernoulli(fes, model)selon la configurationidemE, I (+ A, I_y…)Euler-Bernoulli
timoshenko(fes)w, thetaf_w, m_thetaE, I, G, A_sTimoshenko
frame(fes)u_x, u_y, rzf_x, f_y, m_zE, A, I, G, A_sPortique 2D
frame3d(fes)u_x…r_z (6)f_x…m_z (6)E, A, I_y, I_z, J, G, A_sy, A_szCadre 3D
shell(fes, model)
thick, kirchhoff
u_x…r_z (6)f_x…m_z (6)E, nu, hCoques
dirichlet(…)lambda_<v>imposed_<v>—Dirichlet
mpc(…)lambda_mpcmpc_rhs—Multi-points
embedded(…)lambda_<v>imposed_<v>—Baignage
contact(…)lambda_contactcontact_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
Mechanicaltruss, elasticity, plasticity, mazars, bernoulli, timoshenko, shell
Thermalheat_conduction, radiation ; boundary_transfer et interface_transfer quand leur cible est thermique
Constraintdirichlet, mpc, embedded, contact
Othernature « autre / rien » explicite (aucune physique de base ne la déclare)
Diffusionfick ; boundary_transfer et interface_transfer quand leur cible est une diffusion
Radiationradiation — 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::Other est 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) → un Model ne gardant que les sous-modèles au moins mécaniques ;
  • k.filter(Physics::Mechanical) → une Matrix ne gardant que les blocs au moins mécaniques (non assemblée — relancer Matrix::assemble avant 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 : HeatConduction et BoundaryTransfer (échange de surface / film) (thermique) ; Truss, Elasticity, Plasticity, Mazars, Timoshenko, Frame, Frame3d (mécanique) ; et les contraintes Dirichlet, Mpc, Embedded, Contact (contraintes). Toute nouvelle physique est une struct implémentant SubModelKind (une variante de l’énum SubModel
    • 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.
  • Toutes les physiques n’ont pas tous les genres de matrice : chacune déclare les MatrixKind qu’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 de matrix.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 ; SolveMethod reste 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 la Coords.
  • 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 primales T).

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 (blocs C / 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 :

  1. 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 ; stiffness le mémoïse donc sur le Model et le réutilise d’un assemblage à l’autre.
  2. 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 (NodeId est déjà l’index nœud global dense, et add_entry retrouve 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 dans models, hors de containers, et l’y appeler créerait un cycle matrix ↔ kernel. Il renvoie alors vers ops::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 le Model.
  • Matrix::assemble(&mut self) réassemble une matrice depuis ses blocs seuls, sans Model : c’est le chemin de composition — combiner une sous-matrice neuve (de provenance quelconque) à une matrice existante puis réassembler. La Matrix ne 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 :

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) · s n’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 :

Symmetrysens
Fullle 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
Noneil 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 Model est 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 composition m.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 + b rend 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 de a suivi des DDL que seule b apporte), mais demande une addition creuse complète : retable des variables, remappage et retri des colonnes de b, 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é, ni Half, les deux n’étant pas transposés l’un de l’autre. Le tableau assemblé est symétrique, le drapeau répond false, 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, un SubNodeField (champ aux nœuds) ou un SubElementField (champ aux points de Gauss) — toutes du même type, et pour les champs sur le même support. Son interpolation en x rend une valeur.
  • Evolution — l’agrégat : une liste de SubEvolution, une par zone, exactement comme un NodeField agrège des SubNodeField. Son interpolation en x interpole chaque courbe, puis regroupe les sous-champs résultants en un NodeField / 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] :

PolitiqueEffet 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 un NodeField / 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 SubEvolution depuis 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 :

  1. Introduction — nature du modèle, éléments et degrés de liberté.
  2. Équations continues résolues — forme forte (et faible) du problème.
  3. Forme discrétisée — opérateurs discrets (B, N) et expressions des matrices élémentaires (K, masse/capacité, tangente…).
  4. Variables et matériau — noms primal/dual, composantes matériau, comportement (COMP) et, le cas échéant, état interne.
  5. Mise en donnée (Rust, testé) — exemple Rust exécuté via {{#include}}.
  6. Exemple Python — l’équivalent haut niveau.
  7. 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.

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")

PhysiquetangentgeometricmassComportement (COMP)Particularité de calcul
heat_conduction
isotropic orthotropic anisotropic
——capacité ρ·cpflux K·∇TSymé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_transfer
composantes libres
———h·aAveugle à 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.
radiationanalytique
4σε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")

PhysiquetangentgeometricmassComportement (COMP)Particularité de calcul
fick
isotropic orthotropic anisotropic
——stockage poroflux D·∇cMê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_transfer
composantes 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")

PhysiquetangentgeometricmassComportement (COMP)Particularité de calcul
elasticity
plane_stress plane_strain axisymmetric full_3d · isotropic orthotropic anisotropic
analytique
c’est K
ouiouiσ = 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 lois
perfect isotropic drucker_prager ottosen gurson creep_norton creep_blackburn creep_lemaitre viscoplastic_chaboche viscoplastic_lemaitre_chaboche
analytique ×2
perturbation ×8
ouiouiσ, ε_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 lois
mazars damage_tc sic_sic
aucuneouiouiσ, 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")

PhysiquetangentgeometricmassComportement (COMP)Particularité de calcul
truss—oui N/L·Poui ρAN = 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.
bernoulli
aucun tag
—oui¹ouiM
N, M
N, 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.
timoshenko
aucun tag
—oui¹ouiM, V
N, M, V
N, 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.
shell
thick, kirchhoff
———N_xx…N_xy
M_xx…M_xy
M_drill
Q_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")

PhysiquetangentgeometricmassComportement (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.

voielois
analytiqueperfect, isotropic (le module algorithmique J2)
par perturbationdrucker_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

nomrô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 :

  1. Coords — l’espace des nœuds (dimension géométrique).
  2. Mesh — les éléments (ici des SEG2 alignés sur \([0,1]\)).
  3. FiniteElementSpace — l’interpolation (lagrange1).
  4. Matériau — un ElementField portant la composante "k", fabriqué commodément par element_field::material_field(&model, &[("k", …)]) (les sous-modèles sans matériau, comme Dirichlet, sont ignorés).
  5. Model — model::heat_conduction(&fes), composé par | (union) avec les conditions limites.
  6. Conditions limites :
    • Dirichlet (T imposée) : un sous-modèle model::dirichlet qui 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érique barycenter (nœuds neufs colocalisés). La valeur imposée \(u_d\) s’écrit dans le chargement au slot imposed_T du 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érateur flux (analogue de FLUX/PRES de Cast3M).
  7. Assemblage + résolution — matrix::stiffness puis le solveur solver::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 avec python examples/thermal_square_2d.py après maturin 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étriecomposantes matériau
isotropic (défaut)k
orthotropick_1, k_2, k_3 + le repère matériau
anisotropick_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 :

termeexpressionrô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
tangente4σε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")])
nomrô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 avec python examples/thermal_convection_2d.py après maturin 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étriecomposantes matériau
isotropicD
orthotropicD_1, D_2, D_3 + le repère matériau
anisotropicD_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

primalec (concentration, colonnes)
dualej (flux de matière, lignes)
matériau requisla diffusivité, selon la symétrie
matériau optionnelporo — le coefficient de stockage, exigé par la seule matrice de masse
naturePhysics::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èlel’autre côté est…où il va
boundary_transferune donnée (une valeur ambiante, a_ext_<primale>)au second membre, \( h\,a_\text{ext}\int N\,d\Gamma \), rendu par external_forces
interface_transferune 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ériauentrée du comportementsortie
("T", "q")h_TT / jump_Tflux_T
("c_H2", "j_H2")h_c_H2c_H2 / jump_c_H2flux_c_H2
("u_x", "f_x")h_u_xu_x / jump_u_xflux_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 :

erreurce qu’elle bâtissait
un nom mal tapé, ou une cible qui n’assemble pas ce coupleun é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 échangeun 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-9 sur 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
mpcinterface_transfer
hn’existe pas — un multiplicateur n’est pas un matériauune constante physique
inconnuesajoute lambda_mpcaucune
systèmepoint-selle, indéfinireste défini positif
conditionnementinsensiblese 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 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) ; rho facultatif (masse).
  • comportement (COMP) : effort axial N = E·A·(cᵀ ε c), à partir de la déformation ε (op deformation).

⚠️ 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 (rho composante matériau optionnelle) — pyrucast.matrix.mass ;
  • rigidité géométrique K_g = (N/L)·(I − c⊗c) transverse, sous l’effort axial N (sortie n du 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 :

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

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

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

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

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

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

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

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

Convergence sur Lamé

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

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

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

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

Matrice de masse

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

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

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

Variables et matériau

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

Mise en donnée (Rust, testé)

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

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

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

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

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

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

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

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

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

Exemple Python

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

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

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

Lancement ::

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

import pyrucast

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


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


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

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

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

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

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

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

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

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


if __name__ == "__main__":
    main()

Compléments

Thermomécanique non couplée

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lancement ::

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

import pyrucast

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


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


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

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

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


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


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


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


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

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

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

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

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

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

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


if __name__ == "__main__":
    main()

É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étrieconstantes indépendantesce qu’elle décrit
isotropic2pas de direction privilégiée
orthotropic9trois plans de symétrie orthogonaux
anisotropic21le 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 : S doit rester définie positive, ce qui impose ν_ij² < E_i/E_j. pyrucast le vérifie en inversant S et 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 :

espacecomposantessignification
2-DV1X, V1Yle premier axe matériau ; le deuxième est sa normale dans le plan
3-DV1X…V1Z, V2X…V2Zles 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 :

  1. construire D dans les axes matériau, où l’orthotropie est diagonale ;
  2. le tourner vers les axes globaux ;
  3. le réduire au modèle cinématique (bloc [xx, yy, xy] en déformations planes, sa condensation statique sur ε_zz en contraintes planes, le bloc [rr, zz, θθ, rz] en axisymétrie, le 6×6 complet 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étriecomposantes matériau requises
isotropicE, nu
orthotropicE_1, E_2, E_3, nu_12, nu_13, nu_23, G_12, G_13, G_23 + le repère
anisotropicC_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, avec q = √(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é élastique K = ∫ Bᵀ D B dΩ, opérateur d’itération simple (Newton modifié) ;
  • tangente cohérente (KTAN, assemble.tangent) : K_t = ∫ Bᵀ D_alg B avec le module algorithmique D_alg du 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 aussi D_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) :

  1. prédiction élastique : σ_trial = D : (ε − εᵖ), q = √(3/2 s_trial:s_trial) ;
  2. si f = q − σ_y ≤ 0 → pas élastique, état inchangé ;
  3. 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 plastique eps_p_xx … eps_p_xy (toujours 6 composantes 3-D), déformation plastique cumulée p, et déformation ε(A). C’est la sortie du pas précédent ; None au premier pas (A = configuration de référence, tout à zéro).
  • sortie du COMP (état de B, = prev du 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 (plus sigma_zz pour les modèles plans en 2-D, dont le dual de Voigt l’omet) pour que prev soit complet. Le prédicteur élastique est σ_trial = σ(A) + C:(ε(B) − ε(A)).
  • modèles : plane_stress, plane_strain, axisymmetric (2-D) et full_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/r est mesurée (produite par deformation), pas supposée : ε(B) est donc entièrement connue, sans la résolution hors-plan qu’exige la contrainte plane ;
  • σ_zz fait 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.

loisurfacece qu’elle capturematériau
plasticity_perfectq = σ_ymétal sans écrouissageE, nu, sigma_y
plasticity_isotropicq = σ_y + H·pmétal écrouissable+ H
drucker_pragerα·I₁ + β·q = ksols, roches, poudres — sensibilité à la pression, écrouissage bornéE, nu, friction, k, psi (+ six facultatifs)
ottosen4 paramètres, dépendante de l’angle de Lodebéton — traction ≠ compressionE, 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

Cast3Micidéfautrôle
ALFAfrictionrequispente du cône
Kkrequiscohésion
GAMMpsirequisdilatance du potentiel
BETAbeta1poids déviatorique du critère
DELTdelta1poids déviatorique du potentiel
HH0module d’écrouissage
ETAfriction_ultfrictionpente de la surface ultime
MUbeta_ultbetapoids déviatorique ultime
KLk_ultkcohé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 (et delta = 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.rs l’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.

loivitessece qu’elle décritmatériau
creep_nortonṗ = (q/K)^nfluage secondaire (stationnaire)E, nu, K, n
creep_lemaitreṗ = (q/K)^N · p^(−M)fluage primaire, par écrouissage en déformationE, nu, K, N, M
creep_blackburnprimaire saturant + secondaireles deux stades, dépendance en sinhE, nu, A_1, alpha_1, r_1, B_s, beta_s
viscoplasticity_chabocheṗ = ⟨(J(σ−X) − R − k)/K⟩^nviscoplasticité cyclique+ k, K, n, C_1, gamma_1, b, Q
viscoplasticity_lemaitre_chabochela 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 seuil eps_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’historique kappa. None au premier pas — elle est plafonnée à eps_d0 dans 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, seul kappa est historique.
  • sortie du COMP (= prev du pas suivant) : contrainte (sigma_*), damage (le scalaire D), et kappa mis à jour.
  • modèles : plane_stress, plane_strain, axisymmetric (2-D) et full_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.

loivariable(s)ce qu’elle capture
mazarsun scalaire Dbéton, deux branches mélangées
damage_tcd⁺, d⁻traction et compression séparées — l’effet unilatéral
damage_sic_sicd_1, d_2, d_3composite 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 :

CoordsDDL par nœudmatériauce qui s’ajoute à la flexion
1-Dw, thetaE, Irien — flexion pure
2-Du_x, u_y, r_z+ Al’effort axial, et une rotation vers les axes globaux
3-D6 DDL+ I_y, I_z, J, Gl’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.

CoordsDDL par nœudmatériauefforts de section
1-Dw, thetaE, I, G, A_sM, V
2-Du_x, u_y, r_z+ AN, M, V
3-DsixE, A, I_y, I_z, J, G, A_sy, A_szN, 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' = 0 sur 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.

formulationcisaillement transverseéléments
thick (Reissner-Mindlin)oui, intégré réduitTRI3, QUA4
kirchhoff (DKT/DKQ)imposé nul en des points discretsTRI3, 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éairele 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 C porte 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 :

  1. statut initial : toutes les inégalités actives (ou le statut convergé précédent quand le cache est chaud — warm start) ;
  2. résolution du système point-selle avec, pour chaque relation inactive, sa ligne de contrainte remplacée par λ = 0 (la matrice garde sa taille, seules les valeurs changent) ;
  3. mise à jour du statut : une relation active dont le λ tire (signe inadmissible) est relâchée, une inactive dont le jeu pénètre est activée ;
  4. statut stable ⇒ convergé ; sinon on refactorise et on répète (boucle finie de la méthode du statut classique, bornée par max_iter).
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 NodeField neuf sur tous les nœuds-multiplicateurs (les relations non citées valent 0), à fusionner dans le chargement global avec | : load | dirichlet.constraint_rhs(…) | mpc.constraint_rhs(…).

Le modèle passé doit porter exactement une contrainte (l’objet dirichlet ou mpc). Une erreur est levée si un nœud n’appartient à aucune relation, ou s’il en désigne plusieurs (ambigu).

Désigner par index de relation

Quand un même nœud participe à plusieurs relations (le keying par nœud est alors ambigu), on désigne la relation par son index (0-based, dans l’ordre de relations()) :

rhs = mpc.constraint_rhs_by_index([(index_relation, g)])

Le champ renvoyé et la fusion par | sont identiques ; une erreur est levée si un index dépasse le nombre de relations.

Contraintes disponibles

  • Dirichlet — impose la valeur d’une variable primale (T = u_d, u_x = 0, …) sur un ensemble de nœuds. C’est la relation à un seul terme 1·u = u_d.
  • Multi-points (MPC) — impose une relation linéaire à N termes Σₖ aₖ·u(nœudₖ, varₖ) = g entre plusieurs DOFs (égalité, périodicité, liaison affine…). Généralise Dirichlet.
  • Baignage (embedded) — lie chaque nœud d’un maillage immergé à l’interpolation d’un maillage hôte au même point (u_c(p) = Σᵢ Nᵢ(ξ_p)·u_c(hôteᵢ)) : une barre baignée dans un volume. Les poids Nᵢ sont calculés par localisation de point à la construction ; c’est une MPC dont les coefficients varient par nœud.
  • Contact (nœud-surface) — empêche les nœuds d’un maillage esclave de pénétrer une surface maître : une relation unilatérale (≥) par nœud esclave, à coefficients n·Nᵢ calculés par projection à la construction (petits glissements, sans frottement). Résolu par solve_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 avec imposed_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ôlenomfourniture
le modèle contrainttargetrequis
variable imposée (une primale de target)variable (ex "T")requis
duale de la cible (ligne où atterrit la réaction Cᵀ)lue dans targetdéduit
primale propre = multiplicateur (inconnue du système)lambda_<variable>déduit
duale propre = ligne de contrainte + slot où l’utilisateur écrit u_dimposed_<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_d n’est pas stockée dans le SubModel : l’utilisateur la fournit dans le NodeField de chargement, à la position (multiplier_node, imposed_value) — c’est-à-dire au slot imposed_<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_mesh et multiplier_mesh sont des maillages POI1 (contrainte par nœud). Les contraintes réparties (sur une arête entière) passeront par un bloc C issu d’une intégration, comme flux pour 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 r de 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ême Coords, 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ôlenomdéfaut
primale propre = multiplicateur λ (inconnue du système)multiplierlambda_mpc
duale propre = ligne de contrainte + slot où l’utilisateur écrit gimposed_valuempc_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 g n’est pas stocké dans le SubModel : l’utilisateur l’écrit dans le NodeField de chargement, au slot mpc_rhs du nœud-multiplicateur (défaut g = 0 — le cas homogène des égalités et périodicités).
  • Le multiplicateur se retrouve dans la solution sous lambda_mpc au 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ôlenomdéfaut
primale contrainte (colonne partagée immergé ↔ hôte)variable— (fournie)
duale cible où atterrit la réactiontarget_dual— (fournie, cf. dual_of)
primale propre = multiplicateur λmultiplierlambda_<variable>
duale propre = ligne de contrainte + slot de gimposed_valueimposed_<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) = +1 sur 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éfaut 0, la liaison rigide) s’écrit dans le NodeField de chargement, au slot imposed_<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 POI1 n’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ôlenomdéfaut
primale propre = réaction de contact λmultiplierlambda_contact
duale propre = ligne de contrainte + slot de −g₀imposed_valuecontact_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émentNœudsDim. topo.Domaine de référenceQuadrature (\( n_g \))
SEG221\( \xi\in[-1,1] \)Gauss 2 pts
TRI332simplexe \( \xi+\eta\le 1 \)Hammer 3 pts
QUA442\( [-1,1]^2 \)2×2 Gauss (4)
TET443simplexe \( \xi+\eta+\zeta\le 1 \)Hammer 4 pts
PYRA553pyramide (section carrée décroissante)Gauss×Jacobi conique (8)
PENTA663prisme (TRI3 × \( \zeta \))TRI×Gauss (6)
HEX883\( [-1,1]^3 \)2×2×2 Gauss (8)

Lagrange-2 (quadratique)

ÉlémentNœudsDim. topo.ParentTypeQuadrature (\( n_g \))
SEG331SEG2completGauss 3 pts
TRI662TRI3completDunavant deg. 4 (6)
QUA882QUA4sérendipité3×3 Gauss (9)
QUA992QUA4complet (Q2)3×3 Gauss (9)
TET10103TET4completKeast deg. 4 (11)
PENTA15153PENTA6sérendipitéTRI6×Gauss (18)
HEX20203HEX8sérendipité3×3×3 Gauss (27)
HEX27273HEX8complet (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émentGAUSSREDUCED
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_surface sur 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 un HEX8 par la face carrée et avec un TET4 par 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œudarête\( (\xi, \eta, \zeta) \)
0\( (0,0,0) \)4\( (0,1) \)\( (\tfrac12,0,0) \)
1\( (1,0,0) \)5\( (1,2) \)\( (\tfrac12,\tfrac12,0) \)
2\( (0,1,0) \)6\( (2,0) \)\( (0,\tfrac12,0) \)
3\( (0,0,1) \)7\( (0,3) \)\( (0,0,\tfrac12) \)
8\( (1,3) \)\( (\tfrac12,0,\tfrac12) \)
9\( (2,3) \)\( (0,\tfrac12,\tfrac12) \)

Fonctions de forme

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 RustChapitreContenu
ops::meshMaillageline, 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::modelModèleles déclarations de physique : heat_conduction, fick, radiation, boundary_transfer, interface_transfer, elasticity, plasticity_perfect et les lois d’écoulement, mazars et les lois d’endommagement, truss, bernoulli, timoshenko, shell, et les contraintes dirichlet, mpc, embedded, contact
ops::element_fieldConstructionchamps matériau (material_field…)
ops::coordsChampsset, displace — les deux seuls opérateurs qui écrivent la géométrie
ops::measureChampsintegral / integral_element (∫ f dΩ), xtx / xty (produits scalaires globaux)
ops::geomGéométrielocate_points (mapping inverse, baignage), project_points (projection sur surface, contact) — internes, pas exposées à Python
ops::node_field, ops::element_field, ops::fieldChampspositions, gradient, divergence, deformation, 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::matrixAssemblagestiffness, 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::behaviorComportementintegrate_behavior (le COMP)
ops::solverSolveursolve (LU creux, Lagrange), solve_eliminate (condensation MPC), solve_unilateral (actif/inactif, relations unilatérales)
ops::exportVisualisationexport_vtk (maillage / champ / évolution → VTK ASCII ou binaire pour ParaView), to_arrays, to_gmsh, to_medcoupling
archiveSauvegarde et relecturesave / load (graphe d’objets, partage préservé) — au niveau racine, hors ops
src/vizVisualisationtracé des maillages, coloration par champ

Le découpage est par conteneur produit : gradient(field, fespace) rend un ElementField, il vit donc dans ops::element_field à côté de deformation, et non avec divergence (qui rend un champ nodal). Une opération se range par sa sortie, jamais par son entrée. Le binding Python reste un miroir 1:1 — voir Correspondance Rust ↔ Python.

La fonction libre est la forme canonique : c’est elle qui est documentée dans les chapitres qui suivent. La plupart de ces opérations sont aussi des méthodes de leur sujet, pour permettre le chaînage — maillage.border().consolidate() plutôt que mesh.consolidate(mesh.border(maillage)). La règle qui décide lesquelles, et ses exclusions, tient dans les trois conditions de Conventions.

La chaîne typique d’un calcul enchaîne ces opérateurs :

mesh ──► (Mesh) ──► FiniteElementSpace
                                 │
element_field ── material_field ─┤
                                 ▼
matrix ── stiffness ──────────► (Matrix) ──┐
node_field ── flux/positions ──► (RHS) ──┤
                                           ▼
                          solver ── solve ──► (NodeField solution)
                                           │
   element_field ── 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

PythonRôle
from_live_nodes(coords)un Mesh POI1 de tous les nœuds vivants d’un Coords
poi1_from_nodes(nodes)un Mesh POI1 sur une liste de nœuds donnée
line(a, b, n_elems, element_type="SEG2")une ligne de n_elems éléments (SEG2 ou SEG3) entre deux nœuds (nœuds intermédiaires créés)
circle(center, normal, radius, n_elems, element_type="SEG2")un cercle fermé (SEG2 ou SEG3, plan défini par normal)
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" — chaque QUA4 est coupé en deux TRI3 le long de la diagonale (0, 2) (pas de nœud créé) ;
  • "QUA8" — promotion quadratique via to_quadratic (nœuds de milieu d’arête) ;
  • "QUA9" — comme QUA8, puis un nœud central neuf est ajouté par cellule (moyenne des 4 coins) — to_quadratic ne produit que le QUA8 sérendipité, sans nœud central ;
  • "TRI6" — coupe en TRI3 puis promotion quadratique (mêmes deux étapes composées).
tri = pyrucast.mesh.sweep(mesh_a, mesh_b, 2, "TRI3")  # 2× more cells than QUA4
qua8 = pyrucast.mesh.sweep(mesh_a, mesh_b, 2, "QUA8")
qua9 = pyrucast.mesh.sweep(mesh_a, mesh_b, 2, "QUA9")
tri6 = pyrucast.mesh.sweep(mesh_a, mesh_b, 2, "TRI6")

Surface entre 4 côtés : transfinite

transfinite(side1, side2, side3, side4, element_type="QUA4") maille une surface structurée délimitée par quatre lignes SEG2 — l’équivalent Cast3M de l’opérateur DALL(er), généralisant sweep (2 lignes) à 4 lignes. side1/side3 et side2/side4 sont les deux paires de côtés opposés ; chaque paire doit avoir le même nombre d’éléments. Les quatre côtés doivent former un contour fermé, orienté de façon cohérente :

side4 dernier nœud == side1 premier nœud
side1 dernier nœud == side2 premier nœud
side2 dernier nœud == side3 premier nœud
side3 dernier nœud == side4 premier nœud

Les nœuds des quatre côtés (coins compris) sont réutilisés ; seuls les nœuds intérieurs sont créés, par interpolation transfinie discrète (patch de Coons bilinéaire) : mélange des deux côtés opposés à chaque direction, corrigé par les quatre coins pour reproduire les côtés exactement sur le bord, quelle que soit leur forme (pas seulement des droites). Comme sweep, un QUA4 est toujours construit d’abord, puis converti pour "TRI3"/"QUA8"/"QUA9"/"TRI6".

c = pyrucast.Coords(dim=2)
p0 = c.add_node([0.0, 0.0])
p1 = c.add_node([2.0, 0.0])
p2 = c.add_node([2.0, 1.0])
p3 = c.add_node([0.0, 1.0])

side1 = pyrucast.mesh.line(p0, p1, 4)  # bottom, 4 elements
side2 = pyrucast.mesh.line(p1, p2, 2)  # right,  2 elements
side3 = pyrucast.mesh.line(p2, p3, 4)  # top,    4 elements (= side1)
side4 = pyrucast.mesh.line(p3, p0, 2)  # left,   2 elements (= side2)

surf = pyrucast.mesh.transfinite(side1, side2, side3, side4)
print(surf.element_types(), surf.cell_count())  # ['QUA4'] 8

Différence avec Cast3M. DALL accepte des côtés opposés avec un nombre de points différent (algorithme de pavage plus général, documenté mais non détaillé dans la notice officielle). transfinite se limite au cas standard de l’interpolation transfinie — côtés opposés de même nombre d’éléments — largement suffisant en pratique et implémentable simplement.

Copie sur place : copy

copy(mesh, new_nodes=True) rend une copie du maillage sans le déplacer : mêmes sous-maillages dans le même ordre, mêmes types, mêmes couleurs, même connectivité. Ce que l’argument tranche, c’est si les deux maillages se tiennent encore par leur géométrie :

  • new_nodes=True (par défaut) crée un nœud neuf par nœud distinct, à la même position, dans la même Coords — un nœud partagé entre plusieurs cellules de la source reste partagé dans la copie. Les deux maillages sont alors indépendants : déplacer un nœud de l’un laisse l’autre en place. C’est ce que font les copies rigides, moins le déplacement.
  • new_nodes=False copie la seule connectivité : ce sont les mêmes nœuds, dont le refcount monte. Déplacer un nœud les déplace tous les deux.

Dans les deux cas la copie est descellée (Maillage), donc de nouveau modifiable même si l’original a été consommé par un espace éléments finis, un champ ou une matrice ; c’est la forme fonction libre de la méthode duplicate(), qui est le cas new_nodes=False.

# A copy on its **own** nodes, at the same places: the two meshes no longer
# move together.
jumelle = pyrucast.mesh.copy(face, new_nodes=True)

# A tracing: same connectivity, **same** nodes. It is unsealed, hence editable
# again even if `face` has already been used in a computation.
calque = face.copy(new_nodes=False)

Copies rigides : translate, rotate et les symétries

translate(mesh, vector), rotate(mesh, angle, center, axis=None) et les trois symétries renvoient une copie neuve du maillage — mêmes sous-maillages, mêmes types, mêmes couleurs, même connectivité — dont tous les nœuds sont nouveaux. Le maillage d’origine (et ses nœuds) reste intact ; un nœud partagé entre plusieurs cellules de la source reste partagé dans la copie.

  • translate décale chaque nœud de vector (dont la longueur doit valoir la dimension du maillage).
  • rotate tourne de angle radians autour de center. En 2D, center est un point et axis est ignoré ; en 3D, la rotation se fait autour de la droite passant par center dirigée par axis (formule de Rodrigues, main droite), et axis est obligatoire (il n’a pas besoin d’être normé).
  • symmetry_point(mesh, center) envoie chaque nœud sur 2·center − x : center est le milieu de chaque nœud et de son image. C’est le demi-tour autour du point en 2D, l’inversion centrale en 3D.
  • symmetry_line(mesh, a, b) réfléchit à travers la droite (infinie) passant par a et b : la composante le long de la droite est gardée, la perpendiculaire est retournée. En 2D c’est l’image miroir dans la droite ; en 3D c’est le demi-tour autour d’elle (une rotation de π) — pour l’image dans un miroir, c’est symmetry_plane qu’il faut.
  • symmetry_plane(mesh, a, b, c) réfléchit à travers le plan passant par les trois points a, b et c : x ↦ x − 2((x − a)·n̂) n̂, où n̂ est la normale unitaire du plan. Les trois points jouent des rôles symétriques — seul compte le plan qu’ils engendrent, pas leur ordre (une permutation retourne n̂, ce à quoi la formule est insensible). 3D uniquement : en 2D, le miroir est symmetry_line, qui prend les deux points de la droite. Erreur si les trois points sont alignés (ils n’engendrent alors aucun plan).

Orientation des cellules

Une symétrie peut retourner l’orientation : le déterminant de sa partie linéaire vaut alors −1, et la copie brute aurait toutes ses cellules à l’envers (jacobien négatif, normales rentrantes sur une peau). Ces opérateurs appliquent donc en plus à chaque cellule la permutation de renversement, comme invert, de sorte que la copie ait la même orientation que la source et soit directement calculable. Les cas retournés dépendent de la dimension :

opérateur2D3D
symmetry_pointdirect (demi-tour)retourné
symmetry_lineretournédirect (demi-tour)
symmetry_plane— (3D seulement)retourné

Appliquer invert au résultat redonne la connectivité miroir brute.

    import math

    import pyrucast

    # A TRI3 face (a single triangle) in the z = 0 plane.
    c = pyrucast.Coords(dim=3)
    face = pyrucast.Mesh(c, "TRI3")
    face.unit().add_cell(
        [
            c.add_node([1.0, 0.0, 0.0]),
            c.add_node([2.0, 0.0, 0.0]),
            c.add_node([1.0, 0.0, 1.0]),
        ]
    )

    # Copy translated by 5 along +z (new nodes; `face` is left intact).
    haut = pyrucast.mesh.translate(face, [0.0, 0.0, 5.0])

    # Copy rotated by 30° about the z axis through the origin.
    tournee = pyrucast.mesh.rotate(face, math.pi / 6, [0.0, 0.0, 0.0], [0.0, 0.0, 1.0])

    # Mirror copy in the y = 0 plane, given by three of its points: the missing
    # half of a part meshed on its half-model (cells put back the right way
    # round).
    autre_moitie = pyrucast.mesh.symmetry_plane(
        face, [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]
    )

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 un angle positif) ; axis est ignoré. Seul un SEG2 a du sens : une surface engendrerait un solide, que des coordonnées 2D ne peuvent pas porter (c’est refusé explicitement).
  • En 3D, elle se fait autour de la droite passant par center dirigée par axis (main droite) ; axis est alors obligatoire et n’a pas besoin d’être normé.

Comme pour extrude, la couche 0 réutilise les nœuds de la source (les nœuds partagés entre cellules le restent) et les autres couches sont créées.

Un tour complet referme l’anneau. Avec angle = 2π, la dernière couche de nœuds est la première : le tore/cylindre engendré n’a ni couture ni nœuds en double, et rien à souder après coup (pas de merge_nodes). Au-delà d’un tour, la révolution se recouvrirait elle-même : c’est une erreur.

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_type est déjà le type du sous-maillage : celui-ci est recopié tel quel ;
  • QUA4→TRI3 — chaque quadrangle est coupé en deux triangles selon la diagonale (0, 2) : (0, 1, 2) et (0, 2, 3) ;
  • HEX8→TET4 — chaque hexaèdre est coupé en six tétraèdres partageant la grande diagonale (0, 6) (subdivision de Freudenthal/Kuhn), un découpage qui pave l’espace et reste conforme entre hexaèdres voisins (faces coupées selon la même diagonale).

Les nœuds sommets sont réutilisés (aucune création, aucun déplacement) et les couleurs de face sont conservées. Le maillage d’origine n’est pas modifié. Tout autre couple (source, cible) lève une erreur : passer à un type quadratique (TRI3→TRI6, …), qui crée des nœuds de milieu d’arête, relève de to_quadratic.

faces = pyrucast.mesh.skin(volume)  # QUA4 skin
faces = pyrucast.mesh.convert(faces, "TRI3")  # QUA4 → TRI3
print(faces.element_types())  # ['TRI3']

Maillage d’un contour fermé : triangulate_surface

triangulate_surface(contour, element_type, size=None) remplit l’intérieur d’un contour par triangulation de Delaunay contrainte (CDT) puis raffinement de Ruppert à taille de maille cible, en créant les nœuds internes nécessaires. C’est l’équivalent de l’opérateur Cast3M SURF. Le mailleur est rapide (≈ 3·10⁵ mailles/s) et gère nativement les trous et plusieurs domaines disjoints en une passe.

Le contour est figé : le maillage produit réutilise exactement les nœuds d’entrée (mêmes identifiants, mêmes positions) et n’ajoute aucun nœud sur une arête du contour. Le raffinement n’insère donc que des nœuds intérieurs ; pour un bord plus fin, discrétisez le contour en amont (mesh.line(a, b, 15), mesh.arc(...), mesh.circle(...)).

contour est un Mesh contenant une ou plusieurs boucles SEG2 fermées ; la configuration peut être en dimension 2 (cas direct) ou une boucle plane en 3D (voir Contrôle de planéité plus bas). element_type vaut "TRI3" ou "QUA4" ; size fixe la longueur d’arête visée (par défaut : longueur moyenne des segments de bord de chaque domaine).

Les fondements mathématiques (aire signée, Newell, Delaunay / Bowyer-Watson, CDT, Ruppert) sont rassemblés dans Triangulation : briques mathématiques. Cette page-ci décrit le comportement de triangulate_surface.

Convention d’orientation (à la charge de l’appelant)

triangulate_surface s’appuie sur l’orientation des boucles fournies :

  • une boucle antihoraire (CCW, aire signée > 0) est la frontière extérieure d’un domaine ;
  • une boucle horaire (CW, aire signée < 0) est un trou, contenu dans une boucle extérieure ;
  • plusieurs boucles CCW disjointes maillent plusieurs domaines en une fois.

C’est exactement l’orientation produite par border (extérieur CCW, trous CW), donc la sortie de border réalimente directement triangulate_surface.

Méthode

  1. les points de bord sont insérés un à un dans une triangulation de Delaunay par Bowyer-Watson (super-triangle englobant) ;
  2. chaque arête de boucle absente est récupérée (retrait du corridor + ear-clipping des deux polygones adjacents) puis marquée contrainte ;
  3. la triangulation est légalisée (flips de Delaunay ne traversant aucune contrainte) ;
  4. excavation : un flood-fill depuis un triangle sûrement intérieur, ne traversant jamais une arête contrainte, sépare l’intérieur du domaine des trous et des poches hors d’un bord concave ;
  5. raffinement de Ruppert : les triangles trop plats/trop grands sont coupés par insertion de leur circoncentre. Le contour restant figé, un circoncentre qui empiéterait une arête de bord (ou tomberait hors du domaine) est abandonné plutôt que de couper cette arête — on préserve le contour au prix d’un triangle un peu moins bon près du bord ;
  6. léger lissage laplacien, puis (pour QUA4) recombinaison gloutonne des paires de triangles.

En QUA4 le résultat est donc quad-dominant : les triangles sont recombinés par paires en quadrangles, une poignée de triangles de bord pouvant subsister (sous-maillage TRI3 annexe). Les éléments sont orientés CCW.

Contrôle de planéité (cas 3D)

Une boucle 3D est ajustée à son plan de meilleure approximation (méthode de Newell), maillée dans ce repère 2D local, puis relevée dans l’espace 3D. La déviation maximale d’un nœud du contour à ce plan doit rester inférieure à 1e-6 × diag (diag = diagonale de la boîte englobante) ; au-delà, triangulate_surface retourne une erreur indiquant la déviation observée et la tolérance. Ce seuil relatif tolère le bruit numérique tout en refusant les vrais contours gauches.

Raffinement — convergence

Le raffinement de Ruppert garantit théoriquement sa convergence pour un angle minimal ≤ 20.7° (Shewchuk) ; pyrucast vise 20° et plafonne le nombre d’insertions pour éviter les divergences (erreur explicite si la limite est atteinte). Les nouveaux nœuds (« Steiner ») sont strictement intérieurs et créés dans la Coords du contour, exactement comme les nœuds utilisateur ; aucun n’est posé sur le contour, qui reste figé. Cette contrainte peut laisser subsister, contre un bord grossièrement discrétisé, un triangle plus plat que l’angle visé : affiner alors le contour d’entrée plutôt que la taille cible.

Exemple Python

import pyrucast

c = pyrucast.Coords(dim=2)

# Outer contour: 4×4 square (CCW).
outer = pyrucast.Mesh(c, "SEG2")
outer_nodes = [
    c.add_node(list(p)) for p in [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)]
]
for i in range(4):
    outer.unit().add_cell([outer_nodes[i], outer_nodes[(i + 1) % 4]])

# Hole: centred 2×2 square, oriented CW.
hole = pyrucast.Mesh(c, "SEG2")
hole_nodes = [
    c.add_node(list(p)) for p in [(1.0, 1.0), (1.0, 3.0), (3.0, 3.0), (3.0, 1.0)]
]
for i in range(4):
    hole.unit().add_cell([hole_nodes[i], hole_nodes[(i + 1) % 4]])

# Compose the two contours through the union | (never +).
combined = outer | hole

# TRI3 mesh of size ~0.5 (area = 16 - 4 = 12).
tri = pyrucast.mesh.triangulate_surface(combined, "TRI3", size=0.5)
print(tri.element_types(), tri.cell_count())

# Quad-dominant variant.
quad = pyrucast.mesh.triangulate_surface(combined, "QUA4", size=0.5)
print(quad.element_types())  # ['QUA4', 'TRI3'] in general

Refus final

Comme tous les mailleurs, triangulate_surface mesure ses mailles avant de les rendre et sort en erreur plutôt que d’en livrer une retournée ou plate — un jacobien négatif ou nul n’est intégrable par aucun code éléments finis. Il le gagne par construction (le flood-fill garde les triangles dans le sens du super-triangle, les bascules et le lissage refusent tout mouvement qui en retournerait un, la recombinaison n’apparie que des quadrangles convexes), mais la vérification est faite quand même : une garantie que rien ne contrôle n’en est une que tant que les quatre tiennent.

Interruption

Un maillage trop long s’interrompt par Ctrl+C : triangulate_surface sonde les signaux et lève une KeyboardInterrupt. Côté Rust, triangulate_surface_cancellable(contour, type, size, &cancel) accepte un jeton d’interruption (timeout, drapeau partagé…) — voir Interrompre une fonction.

Limitations actuelles

  • Taille uniforme : pas encore de champ de densité variable par nœud (un size façon CHPO1).
  • L’orientation est à fournir par l’appelant ; une boucle mal orientée mène à une erreur (aucune boucle extérieure) ou à un domaine inattendu.
  • Les boucles doivent être deux à deux disjointes (pas de trous emboîtés, pas de croisements).

Côté Rust, ops::mesh::triangulate_surface(&contour, ElementType::TRI3, Some(0.5)). Le cœur (CDT + raffinement) opère sur de simples Vec<Point2> sans toucher à la Coords ; lissage et recombinaison QUA4 sont parallélisés (rayon). Le module pyrucast::ops::mesh::triangulation regroupe par ailleurs les briques géométriques réutilisables indépendamment du système Mesh (voir Triangulation).

Pavage frontal d’un contour fermé : pave_surface

pave_surface(contour, element_type, size=None, 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_surfacepave_surface
méthodeDelaunay contraint + Ruppertfront avançant
élément naturelTRI3QUA4
QUA4 obtenu parrecombinaison de pairesconstruction
rangées alignées sur le bordnonoui
tout-quadrangle garantiimpossibleall_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.

  1. Une rangée par tour. À chaque nœud du front, le nombre de quadrangles voulus est \( k = \operatorname{round}(\theta / 90°) \), borné à \( 1..4 \), où \( \theta \) est l’angle intérieur. Ce n’est pas un seuil réglé : si \( k \) quadrangles entourent le nœud, ses voisins forment un chemin de \( k+1 \) sommets, dont \( k-1 \) sont neufs — vouloir des angles droits fixe \( k \). Les nouveaux nœuds se placent sur les rayons qui découpent le secteur en \( k \) parts égales.

  2. Refus et retrait. Un quadrangle non strictement convexe a un jacobien négatif à son coin rentrant : aucun code éléments finis ne peut l’intégrer. Une rangée qui en produirait un, dont les arêtes croiseraient le front, ou qui laisserait sa boucle retournée, est refusée ; le planificateur dit quels nœuds sont en cause, et la rangée est reprise moins loin à cet endroit seulement. Le retournement est le cas subtil : une rangée consomme de la matière, elle ne peut donc que rétrécir la région bornée par sa boucle, jamais en inverser le sens. Celle qui le fait est celle posée sur une lèvre — le résidu que deux fronts se rencontrant de face laissent entre eux, déjà plus mince que la maille voulue. Ses quadrangles passent tous les tests locaux, sa chaîne ne croise rien, et la boucle ressort à l’envers ; à partir de là chaque rangée suivante la gonfle au lieu de la réduire, jusqu’à sortir de la matière et entraîner avec elle toute couture rencontrée. Le signe de l’aire orientée distingue les deux cas sans ambiguïté — une boucle extérieure décroît vers zéro par le haut, le front d’un trou s’en éloigne par le bas, et chacune garde son signe sa vie durant. La lèvre refusée est laissée à la fermeture, qui la remplit ou la soude sur place.

  3. Détente. Une rangée fraîchement posée garde les coudes que le placement sur les bissectrices lui a laissés, et ils se composent : un nœud à 216° offre à ses voisins un secteur qu’aucun gabarit ne remplit bien, la rangée suivante est refusée et la boucle cale. Le front est donc relâché le long de lui-même — un nœud n’a de voisins engagés que derrière lui, un laplacien ordinaire le ramènerait d’où il vient. Chaque déplacement est pesé sur les quadrangles que le nœud porte déjà ; mais ceux-ci sont tous derrière lui et ne voient rien de la ligne devant, si bien que deux portions du front peuvent glisser l’une sur l’autre sans qu’aucun test local ne bronche. La boucle est donc aussi interrogée sur sa simplicité, et une détente qui la lui coûte est reprise en entier. Sans cela, un cercle à vingt côtés maillé au cinquième de son rayon rendait une boucle croisée, donc irremplissable, et le mailleur abandonnait ; il le pave. Mesuré une fois par appel plutôt qu’une fois par passe : +14 % contre +31 % sur un maillage que le front pose seul, pour les mêmes cas sauvés — et gratuit dès qu’une grille fait le travail.

  4. Couture. Deux nœuds de front qui se rapprochent à moins d’environ une demi-maille sont identifiés. La même opération scinde une boucle quand les deux nœuds lui appartiennent — c’est ainsi qu’une géométrie concave se divise — et joint deux boucles sinon — c’est ainsi qu’un trou est absorbé. Les trous n’ont donc aucun traitement particulier.

    Identifier réécrit sur le survivant toutes les mailles qui utilisaient l’autre nœud, triangles compris : un triangle resté accroché au nœud abandonné pendant que ses voisins déménagent est une maille pendue à un nœud que plus rien ne touche, soit trois fissures d’un coup. Et le survivant peut être un nœud du contour — il doit l’être dès que l’autre en est un, puisqu’un nœud du contour ne s’abandonne jamais. Deux coutures successives peuvent alors poser les deux bouts d’une même arête sur deux nœuds du contour qui en ont un troisième entre eux : cette arête longe le bord en enjambant un nœud, et la lentille d’aire nulle qui reste n’est refermable par aucune soudure, tous ses nœuds étant intouchables. Elle est recousue : la maille qui porte la corde reprend en elle les nœuds qu’elle a sautés. Elle ne grossit de rien, la lentille ne couvrant rien, et le bord redevient entier.

  5. Déblocage. Une boucle qui n’avance plus est coupée en deux par une corde, et les deux moitiés reprennent le pavage.

  6. Fermeture. Une boucle réduite à six nœuds ou moins est remplie par décomposition, sans jamais découper une arête (ce qui laisserait un nœud en T, donc un maillage non conforme). Encore faut-il qu’elle soit remplissable : une boucle qui se croise ne l’est pas, ses deux lobes tournant en sens contraire, et toute décomposition en laisse au moins un morceau retourné. Le signe de l’aire ne suffit pas à les repérer — quand les deux lobes se valent, l’aire qu’ils enferment s’annule presque et le signe qui reste est celui du plus gros, un accident. La boucle est donc aussi interrogée sur sa simplicité ; si elle est mince, elle est soudée comme une lèvre. C’est ce qui manquait : sur un cercle, deux mailles ressortaient à jacobien négatif.

  7. Nettoyage topologique, puis lissage sous garde de validité qui ne déplace jamais un nœud du contour. Dans cet ordre : lisser un nœud qui n’a pas le bon nombre de mailles autour de lui ne fait qu’étaler l’erreur sur ses voisins.

  8. Refus final. Chaque maille est mesurée : aucune ne sort d’aire nulle ou négative. Toutes les étapes ci-dessus ont déjà leur garde, exacte et locale ; celle-ci est le filet sous toutes les autres, et ce qu’elle attrape est une garde qui a laissé passer quelque chose. Le maillage n’est alors pas rendu : l’opérateur sort en erreur, en situant la pire maille. Une maille retournée a un jacobien négatif et une maille plate n’en a pas ; ni l’une ni l’autre n’est intégrable, donc un maillage qui en porte une n’est pas un maillage médiocre mais un maillage faux. Mieux vaut une erreur qu’un maillage faux — c’est la règle, et elle vaut pour tous les mailleurs.

Le nettoyage corrige ce que le lissage ne peut pas atteindre, parce que c’est de la connectivité et non de la géométrie :

  • un doublet — un nœud intérieur n’ayant que deux mailles autour de lui, qui partagent donc deux arêtes. Il reste coincé dans un coin quelles que soient les positions ; fusionner les deux mailles supprime le nœud et le coin d’un coup ;
  • une valence fautive. Un nœud intérieur veut quatre mailles : avec trois, les angles valent 120° en moyenne, avec cinq, 72°, et aucun lissage n’y peut rien puisque les angles autour d’un nœud somment à 2π quelles que soient les positions. Deux mailles voisines forment un hexagone, qui se recoupe selon l’une de ses trois diagonales ; changer de diagonale déplace une unité de valence. C’est le seul geste, il ne change ni le nombre de nœuds ni le bord, et il n’est appliqué que s’il fait strictement baisser l’erreur de valence.

Le nombre de mailles voulu à un nœud est le même \( \operatorname{round}(\theta / 90°) \) que dans la classification des rangées, avec ici \( \theta \) la somme des angles incidents : \( 2\pi \) à l’intérieur — d’où le quatre familier — et moins au bord, ce qui donne trois le long d’une arête droite et deux dans un coin droit. Une seule formule, aucun cas particulier « nœud de bord ».

Toutes les décisions topologiques — convexité, croisement de segments — passent par le prédicat exact orient2d (technique de Shewchuk, partagé avec le mailleur volumique). Ce ne sont donc pas des estimations.

Le contour est intouchable

Tous les nœuds du contour reviennent dans le maillage, à leur position, et aucun nœud n’est jamais ajouté sur une arête de bord. La discrétisation du bord appartient à l’appelant : elle porte en général les conditions aux limites, et un nœud glissé au milieu d’un segment serait un nœud que personne n’a demandé. Rien dans le paveur ne découpe une arête de bord, et la couture — la seule opération qui abandonne un nœud — refuse d’abandonner un nœud de contour.

Corollaire : un contour avec lequel le paveur ne peut pas travailler est signalé, pas contourné. Deux cas, tous deux renvoyés en erreur nommant le problème :

  • all_quad sur une boucle à nombre impair de segments. Un polygone à nombre impair de côtés n’admet aucun remplissage en quadrangles seuls ; le pavage ne peut pas changer cette parité — une rangée la conserve, une couture retire deux nœuds — et rééquilibrer le compte reviendrait à ajouter un nœud au bord ;
  • un contour si grossier, ou si irrégulier pour la taille demandée, que le front se replie sur lui-même en laissant une région impossible à remplir. L’erreur indique l’endroit.

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.

relaxmaillespire mailleaire médiane
"free" (défaut)6000,5410,62
"along"4001,0001,00
"none"4001,0001,00
  • "free" — le nœud va où le laplacien le pousse. C’est le comportement historique, et le seul qui ne laisse jamais le front se plisser.
  • "along" — le même déplacement, projeté sur le front : l’écartement s’égalise encore, la forme n’est plus rabotée.
  • "none" — le front reste exactement où la rangée l’a posé.

Il n’y a pas de bonne réponse universelle, et c’est pourquoi c’est un choix. "along" et "none" gagnent sur tout ce qui a des angles à garder ; sur une courbe, il n’y a rien à préserver et tout à redresser. Mesuré à la taille 1 :

forme"free""along""none"
carré 20 × 20600 mailles, pire 0,541400, 1,000400, 1,000
L374, 0,315287, 0,449290, 0,564
profil crénelé859, 0,240620, 0,546554, 0,441
bande étroite 40 × 3581, 0,650557, 0,662559, 0,564
cercle R = 10569, 0,105633, 0,257639, 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.consolidate après avoir uni les côtés.
  • Orientation. Un trou doit être CW. pyrucast.mesh.invert retourne un cercle construit en CCW.
  • La taille du contour compte. Le front part de la discrétisation du bord et converge vers size en quelques rangées. Un contour beaucoup plus grossier que size donne donc des premières rangées plus grosses que demandé.

Limitations actuelles

  • La taille des mailles n’est pas uniforme : l’espacement le long du front et la distance d’avance ne sont pas asservis l’un à l’autre, d’où un élancement médian proche de 2 et une taille d’arête étalée d’un facteur 10. Les angles, eux, restent droits.
  • Deux fronts qui se rejoignent de face laissent normalement un éclat de recouvrement, dégénéré et sans matière : il est écarté. Au-delà d’une demi-maille d’aire, en revanche, c’est une région perdue, et le paveur sort en erreur en indiquant l’endroit plutôt que de rendre un maillage troué.
  • Front convexe sans coin. Un front ne perd des nœuds que par couture, et une couture n’est acceptée que si elle laisse toutes les mailles valides. Un contour circulaire n’a aucun coin : son front garde donc son nombre de nœuds pendant qu’il se contracte, ses arêtes raccourcissent, et les rangées finissent par ne plus tenir. Le paveur s’en sort par des cordes de découpage, mais le débit s’effondre — de l’ordre de 10³ mailles/s sur un disque finement discrétisé, contre 10⁵ sur la plaque trouée. Une géométrie comportant des coins, ou un contour discrétisé près de la taille visée, ne rencontre pas ce cas.
  • Pas de champ de taille variable : size est uniforme par domaine.
  • Un bras étroit n’a pas d’intérieur. C’est la forme aiguë du défaut précédent. Dans un bras de quatre ou cinq mailles de large — une tour de créneau, une nervure — le front avance d’une maille depuis chaque paroi et les deux se rencontrent après deux rangées : il ne reste aucune place où poser des mailles franches, et toute la largeur est ligne de collision. La première couche épouse bien la paroi, mais elle est déjà écrasée par sa jumelle d’en face. Sur le profil crénelé, les tours sortent à un élancement médian de 1,32 contre 1,11 pour grid_surface, et le maillage entier coûte 407 mailles contre 271. Là encore, pour une forme faite de bras rectilinéaires, c’est grid_surface qu’il faut.
  • Un rectangle ne donne pas une grille. C’est la limite de fond, et elle n’est pas réparable dans un paveur frontal : quatre fronts partis des quatre côtés se rencontrent en quatre coutures diagonales convergeant vers deux points singuliers. Sur un rectangle 0,6 × 0,3 à 0,02, le paveur rend 650 mailles et 6 triangles là où la grille en demande 450. Pour une forme rectilinéaire, c’est grid_surface qu’il faut.

Cœur en grille, bande frontale : grid_surface

grid_surface(contour, element_type, size=None, band=0, 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

  1. Les lignes de la grille viennent du contour, pas de la boîte englobante. Toute arête axiale assez longue pour être une caractéristique fixe une ligne à sa coordonnée, et les intervalles entre lignes consécutives sont subdivisés uniformément à peu près à size. Un contour sans aucune arête axiale — un cercle — ne fixe rien et on retombe proprement sur la grille uniforme.
  2. Les mailles entièrement dans la matière sont gardées. Une arête de bord posée exactement sur une ligne de grille ne coupe aucune des deux mailles qu’elle sépare : elle passe entre elles.
  3. Un nœud de grille qui tombe sur un nœud du contour est ce nœud — le même sommet, pas une copie. Le cœur rejoint donc le bord au lieu de s’arrêter à un cheveu de lui.
  4. Le cœur recule seulement là où il ne rejoint pas le contour. Une face du cœur dont les deux extrémités sont des nœuds du contour et qui est un segment du contour est finie ; ailleurs le cœur s’arrête près du bord, à une distance quelconque entre zéro et une maille, et la maille derrière cette face est retirée pour laisser au front une maille pleine où travailler. band en retire davantage.
  5. La bande à paver est le contour et le bord du cœur moins les arêtes communes : un segment parcouru une fois dans chaque sens ne borne rien. Sur un domaine rectilinéaire posé sur la grille, il ne reste rien du tout, aucun front ne tourne, et le maillage est la grille.
  6. Une boucle restante entièrement issue du cœur est gelée. Elle reste vivante — le front la voit, s’en écarte, se coud dessus — mais ne pose aucune rangée. Deux fronts vivants se rencontrent où ça tombe ; un seul front atterrit, sur une interface qu’on a choisie.

L’orientation est détectée sur le contour

Rien dans le contrat n’attache la grille aux axes du repère : son orientation est un choix purement interne. Elle est donc prise sur le contour — l’angle qui rend axiale la plus grande longueur de contour, cherché sur un quart de tour, ce que la symétrie d’ordre 4 d’une grille laisse de distinct.

Sans cela la méthode ne serait qu’un tour de passe-passe ne marchant que sur les formes qu’on a dessinées d’équerre. Rectangle 0,6 × 0,3, taille 0,02 :

angleavant la détectionaprès
0°450 mailles, 0 triangle, qualité 1,000identique
5°496 (28 tri), 64 % de mailles parfaites450, 0 tri, 100 %
15°470 (20 tri), 62 %450, 0 tri, 100 %
30°454 (20 tri), 61 %450, 0 tri, 100 %
45°500 (30 tri), 55 %450, 0 tri, 100 %

À 30° la pire maille était moins bonne qu’avec pave_surface seul. Le profil crénelé tourné de 23,7° retrouve lui aussi ses 4 032 mailles parfaites.

La boîte englobante s’en trouve resserrée, pas agrandie : elle est calculée sur les points déjà tournés. À 30° elle passe de 0,670 × 0,560 — soit 2,08 fois l’aire de la pièce, autant de mailles classifiées pour rien — à 0,6 × 0,3.

Deux garde-fous :

  • un contour sans direction dominante — un cercle, dont les angles d’arête sont répartis uniformément — garde les axes : tourner échangerait une orientation arbitraire contre une autre, au prix de la reproductibilité ;
  • les axes gagnent les égalités. Une forme déjà d’équerre est le cas que cette détection ne doit surtout pas casser, et il serait mauvais de le perdre pour un gain de la largeur d’un arrondi ailleurs. Un chanfrein à 30° sur une pièce par ailleurs droite ne fait donc pas tourner toute la grille.

Reste le cas des directions concurrentes : une pièce mêlant des bords à 0° et à 30° ne peut satisfaire que l’une des deux familles. C’est la plus longue qui est servie.

Le contour doit être discrétisé pour une grille

C’est la seule chose demandée à l’appelant, et c’est une vraie contrainte. Une grille ne peut rejoindre qu’un contour dont les nœuds tombent sur ses lignes, et ses lignes sont dictées par la forme elle-même.

Soit un profil de 0,6 de large fait de neuf créneaux. Les créneaux posent une ligne tous les 0,0667 ; des mailles de 0,00375 donnent donc 18 colonnes par créneau, à 0,0037037. Une base discrétisée d’un seul tenant en 160 segments de 0,00375 les manque toutes, de 1,2 % — assez pour qu’aucun nœud ne soit partagé et que tout le bord retombe sur le front. Couper cette base sous chaque créneau, pour que chaque morceau prenne ses 18 segments, ne coûte rien et partage tous les nœuds.

La règle est donc : couper chaque côté aux angles de la forme, et laisser chaque morceau prendre un nombre entier de mailles. Rien ne le vérifie, parce qu’un contour qui ne le fait pas n’est pas une erreur — il obtient simplement plus de bande et moins de grille.

Exemple Python

import pyrucast as pc

H = 0.02  # target size
coords = pc.Coords(2)

# An L shape. Each side is cut into a whole number of cells of size H, so all
# its nodes fall on the lines the grid will draw from the corners.
angles = [(0.0, 0.0), (0.6, 0.0), (0.6, 0.2), (0.3, 0.2), (0.3, 0.4), (0.0, 0.4)]
noeuds = [coords.add_node(list(p)) for p in angles]

contour = None
for i, a in enumerate(angles):
    b = angles[(i + 1) % len(angles)]
    n = round(((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5 / H)
    seg = pc.mesh.line(noeuds[i], noeuds[(i + 1) % len(angles)], n)
    contour = seg if contour is None else contour | seg
contour = pc.mesh.consolidate(contour)

maillage = pc.mesh.grid_surface(contour, "QUA4", size=H)
print(maillage.element_types())  # ['QUA4'] — not a single triangle
print(maillage.cell_count())  # 450: the exact grid of the L shape

Qualité

Le profil crénelé de 0,6 × 0,3 à sept angles rentrants, taille visée 0,00375 :

pave_surfacegrid_surface
mailles6 428 QUA4 + 26 TRI34 032 QUA4, 0 TRI3
qualité — min0,2291,000
qualité — médiane0,8541,000
mailles sous 0,73,13 %0
aire couverteexacteexacte

Un tiers de mailles en moins pour une qualité parfaite. Sur un rectangle 0,6 × 0,3 à 0,02 : 450 mailles de qualité 1, contre 650 et 6 triangles.

Pièges

  • band vaut 0 et c’est la bonne valeur. Le cœur recule déjà d’une maille partout où il ne rejoint pas le contour. Ne l’augmentez que pour éloigner volontairement l’interface du bord.

  • relax gouverne la bande, pas le cœur — la grille est posée droite quoi qu’il arrive. Voir la relaxation du front : sur une bande épaisse et rectilinéaire, "along" la garde structurée.

  • Pas de gradation, et c’est un choix. La grille est uniforme par intervalle entre lignes. Un quadtree la graduerait — règle 2:1 et gabarits de transition — mais le résultat ne vaut pas ce qu’il coûte, et la raison est géométrique, pas algorithmique : passer de \( n \) nœuds à \( n/2 \) le long d’une interface en gardant tous les angles droits est impossible. La maille qui absorbe la réduction a trois nœuds d’un côté et deux de l’autre — ce n’est plus un rectangle, par construction. Tout raffinement local conforme paie donc en angles à 45°, et un quadtree en paie beaucoup : la gradation par distance au bord fait des couronnes concentriques, chaque feuille de couronne a un côté raffiné, un bord à cinq arêtes n’admet aucun découpage en quadrangles (dans tout quadrillage \( 4Q = 2E_\text{int} + E_\text{bord} \), donc \( E_\text{bord} \) est pair), et il faut un triangle par feuille. Mesuré sur un rectangle : 60 % de mailles en moins, mais un dixième de triangles et un jacobien minimal qui tombe de 1,000 à 0,70.

    La seule gradation compatible avec des rectangles parfaits est celle qui ne change aucun compte de nœuds : espacer les lignes de la grille en progression géométrique. Elle est par axe — une colonne fine traverse toute la hauteur — donc pas localisable, mais elle garde le jacobien à 1. Ce n’est pas fait non plus.

Le contour, nœud par nœud

La garantie est plus forte que « le bord est respecté » : tous les nœuds du contour reviennent, à leur position, avec leur identité, et le maillage n’a aucune autre arête de bord. Un mailleur en grille ne l’hérite pas — il pose ses propres nœuds — il ne la tient que parce qu’un nœud de grille tombant sur un nœud du contour est ce nœud.

Deux drapeaux distincts le portent, et les confondre coûtait cher :

  • immobile — le nœud ne doit pas bouger au lissage : ceux du contour, et ceux du cœur, qui sont d’équerre par construction et n’ont rien à y gagner ;
  • inaliénable — rien ne doit l’abandonner : ceux du contour, et eux seuls. Un nœud du cœur est à nous ; la soudure peut le céder, et c’est ce qui lui permet de refermer une lèvre entre cœur et contour. Tant que les deux drapeaux n’en faisaient qu’un, ces lèvres restaient ouvertes : sur un cercle, 88 arêtes de bord n’appartenaient pas au contour, soit 22 trous.

Dégradation

Une région trop mince pour tenir une seule maille de cœur n’en reçoit aucune, et le front la pave seul, exactement comme pave_surface. Un contour hors grille reçoit une bande large et le même traitement. La dégradation est continue : au pire la qualité du paveur frontal.

Continue, mais bornée par le bas par la validité. Si le front s’emmêle au point de laisser une maille retournée ou plate, l’opérateur refuse au lieu de rendre le maillage : un jacobien négatif n’est pas une maille médiocre mais une maille fausse, qu’aucun code éléments finis n’intègre. Mieux vaut l’apprendre ici qu’à l’assemblage.

Sur un cercle — le cas dégradé par excellence, aucune arête axiale — il ne reste aucune fissure : toutes les arêtes de bord sont celles du contour. Il manque 0,18 % d’aire, les lèvres que la soudure referme. Ce n’était pas acquis : tant que la retraite du front pouvait poser des rangées à 1,5 % de la taille demandée, ces rangées laissaient 16 arêtes orphelines, soit 4 trous d’une maille.

La phase de la grille est déjà la bonne

Une fois l’orientation trouvée, on pourrait vouloir translater la grille selon ses axes pour améliorer la bande. Deux raisons de ne pas le faire, la seconde mesurée.

D’abord, la phase n’est pas libre dans les cas visés : le calage sur les arêtes du contour est un mécanisme de phase. Une ligne épinglée par une arête verticale passe exactement par elle, et fill subdivise chaque intervalle séparément, si bien que chaque ligne de forme est atteinte exactement. Il n’y a rien à gagner là où l’opérateur brille.

La phase n’est donc libre que lorsque rien n’est épinglé — une courbe. Un balayage de huit phases sur un cercle a été mesuré : la phase actuelle gagne sur tous les critères — le moins de mailles, le moins de triangles, et la seule à n’ouvrir aucune fissure.

Ce n’est pas un hasard : ancrer sur le coin de la boîte englobante fait passer les lignes de grille exactement par les points extrêmes du contour, qui sont ses points de tangence. Une phase quelconque les manque, et chaque ligne manquée se paie en mailles de raccord.

Lignes par nœud, rangées pliées : grid_surface2

grid_surface2(contour, element_type, size=None, band=0, 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 :

formegrid_surfacegrid_surface2
rectangle, et toute forme pile sur la grille0,9990,999
plaque à marche hors grille0,4050,963
L à cotes quelconques0,4370,979
L dont les côtés découpent 5+6 contre 4+70,4210,963
L dont les côtés diffèrent d’un nœud0,3510,606
profil crénelé, base coupée sous chaque barre0,3820,916
profil crénelé, base d’un seul tenant0,3230,651
maison à toit à deux pentes0,4200,548, 1 triangle contre 11
carré à un angle arrondi0,2440,400
cercle R = 10,288, p5 0,7960,005 — à ne pas utiliser

La comparaison des quatre mailleurs surfaciques, figures à l’appui, est sur la page Mailler une géométrie.

Donc : grid_surface2 pour une forme rectilinéaire, d’autant plus si ses côtés n’ont pas été coupés aux angles qui leur font face ; grid_surface pour tout ce qui est courbe, où suivre les nœuds du contour revient à suivre le hasard de l’endroit où ses sommets sont tombés.

Exemple Python

maillage = pc.mesh.grid_surface2(contour, "QUA4", size=H)
# or, as a method:
maillage = contour.grid_surface2("QUA4", size=H)

Améliorer un maillage existant : regularize, cleanup, merge_triangles

Les trois opérateurs font sur n’importe quel maillage ce que les paveurs font déjà à leur propre ouvrage en fin de course. Ils sont séparés et composables : l’ordre utile est en général triangles, puis topologie, puis géométrie, mais rien ne l’impose.

Deux marges, et une seule est de la géométrie

Un maillage peut être mauvais de deux façons sans rapport, et il ne faut pas les confondre.

La géométrie, c’est où sont les nœuds, et c’est ce qu’un lissage corrige. La topologie, c’est qui est voisin de qui, et aucun lissage ne peut y toucher : un nœud intérieur entouré de trois quadrangles a des coins à 120° en moyenne, et les déplacer n’y changera rien puisque les angles autour d’un nœud somment à 2π quelles que soient les positions. D’où cleanup à côté de regularize et non dedans.

Le bord n’est jamais touché

Tout nœud porté par une arête de bord — une arête qu’une seule maille utilise — est épinglé : il ne bouge pas, et rien ne l’abandonne. Le maillage garde donc exactement le bord avec lequel il est entré, ce qui est la promesse que les paveurs font déjà sur le contour qu’on leur donne.

regularize — la géométrie

angular=True (défaut) utilise le lissage angulaire : chaque voisin n propose la position obtenue en amenant n → v sur la bissectrice de l’angle que n voit, à longueur inchangée, et on moyenne les propositions. Elle vise l’angle droit là où le laplacien ne vise que le barycentre.

Deux garanties tiennent quelle que soit la règle, parce que le balayage est celui des paveurs : aucun nœud de bord ne bouge, et une position n’est retenue que si toutes les mailles incidentes restent valides et que la pire qualité incidente ne baisse pas.

Les deux se valent désormais, et c’est mesuré. Sur le cercle pavé (60 côtés, taille 0,05, 1 236 mailles) après 60 balayages :

qualité minp1p5
brut0,3660,4710,800
laplacien0,8060,8070,863
angulaire0,7810,8070,865

Les voilà à égalité au premier centile, le laplacien devant sur la pire maille et l’angulaire devant au cinquième centile. L’angulaire dominait franchement les trois colonnes tant que le maillage brut portait ses nœuds irréguliers : depuis que cleanup sait effondrer l’étoile d’un nœud à trois mailles, il en reste peu, et c’est précisément sur ceux-là que la règle angulaire prenait son avantage. Les enchaîner — angulaire puis laplacien — reste ce qui marche le mieux.

cleanup — la topologie

Un doublet est un nœud intérieur n’ayant que deux quadrangles autour de lui, qui partagent donc deux arêtes : le nœud est coincé dans un coin qu’aucun lissage n’ouvrira. Une valence fautive est un nœud intérieur qui veut quatre mailles et en a trois ou cinq.

Trois gestes. Le premier est purement topologique ; les deux autres ne peuvent pas l’être, et c’est le cœur de l’affaire.

  1. Bascule de diagonale, pour une valence trop forte : deux quadrangles partageant une arête forment un hexagone, qui se recoupe selon l’une de ses trois diagonales. Elle ne change ni nœud ni bord, et n’est appliquée que si elle fait strictement baisser l’erreur de valence en laissant les deux mailles convexes.
  2. Effondrement de l’étoile, pour un nœud intérieur qui n’a que trois mailles autour de lui. Ni le doublet — qui en veut deux — ni la bascule — qui en veut deux quadrangulaires — ne l’atteignent.

Le second geste tient tout entier dans une identité. Autour d’un nœud portant ( q ) quadrangles et ( t ) triangles, chaque quadrangle pose deux arêtes qui ne le touchent pas et chaque triangle en pose une : l’étoile du nœud est bordée par un polygone à

[ n = 2q + t ]

côtés. Réciproquement, une décomposition d’un ( n )-gone en ( q’ ) quadrangles et ( t’ ) triangles sans nœud intérieur vérifie

[ 2q’ + t’ = n - 2. ]

Avec ( q + t = 3 ), les deux se combinent en ( 2q’ + t’ = 2q + t - 2 ) : la redécoupe existe toujours, et toujours avec une maille de moins que l’étoile qu’elle remplace.

( q, t )bord de l’étoileavantaprèsmailles
3, 0hexagone3 quadrangles2 quadrangles3 → 2
2, 1pentagone2 quadrangles, 1 triangle1 de chaque3 → 2
1, 2quadrangle1 quadrangle, 2 triangles1 quadrangle3 → 1
0, 3triangle3 triangles1 triangle3 → 1

Trois mailles autour d’un nœud intérieur, c’est une de trop quelles qu’elles soient : les coins font 120° en moyenne et aucun lissage ne les redressera, puisque les angles autour d’un nœud somment à 2π quelles que soient les positions. C’est le seul geste de cleanup qui supprime un nœud, le seul qui change le nombre de mailles, et le seul qui déplace quoi que ce soit. La coupe est prise selon celle des rotations qui laisse le maillage au mieux, et le geste s’applique si l’un des deux progresse : l’erreur de valence baisse strictement, ou la pire des nouvelles mailles bat la pire des anciennes. Il est refusé net si une maille sortirait retournée.

Pourquoi il faut relaxer avant de juger

Supprimer un nœud étire mécaniquement l’anneau qui restait autour : les six nœuds de l’hexagone ne bougent pas, et deux mailles s’y logent là où il y en avait trois. Mesurée sur-le-champ, la redécoupe paraît donc presque toujours plus mauvaise que l’étoile qu’elle remplace — alors qu’elle sera bonne dès que les nœuds auront été relâchés, ce qui arrive toujours : les paveurs lissent après chaque rangée, et regularize est l’étape suivante de l’appelant.

C’est la différence de fond avec la bascule de diagonale, qui, elle, ne déplace aucun nœud : ce qu’elle mesure est définitif, et elle peut s’imposer un plancher de qualité immédiat (QUALITY_FLOOR, 70 % de ce qui était là) sans rien s’interdire d’utile. Le même plancher appliqué à l’effondrement supprime 53 gestes utiles sur 61 — mesuré.

D’où l’ordre retenu : appliquer la découpe, relaxer l’anneau, mesurer, et défaire entièrement si la pire maille du voisinage a baissé. Et lorsque le geste est gardé, la relaxation est gardée avec lui : juger sur des positions qu’on rejetterait ensuite reviendrait à mesurer un maillage que personne ne reçoit — c’est mesuré aussi, et cela coûtait une maille à 0,055 sur la maison.

Un dernier garde-fou tient l’entrée : rien n’est tenté si le geste n’apporte ni valence ni forme. C’est que le verdict ci-dessus juge le voisinage, donc localement, et ne voit pas qu’une maille médiocre ailleurs vient de devenir la pire du maillage. Le retirer fait gagner quelques nœuds réguliers de plus et perdre la garantie de non-régression sur la pire maille — l’échange est mauvais : une pire maille qui recule casse un calcul, treize nœuds irréguliers de plus ne se voient nulle part.

Aucun nœud du contour ne bouge, et aucun n’est abandonné : c’est la garantie qui tient, la même que pour les trois opérateurs, et celle avec laquelle raisonner. « Rien ne bouge du tout » n’en est pas une : cleanup déplace les nœuds des anneaux qu’il a effondrés, et seulement ceux-là. Partout ailleurs il rend vos nœuds tels quels, et le maillage que vous lui donnez n’est jamais modifié.

Les deux dernières lignes du tableau perdent un triangle chacune — deux d’un coup, donc la parité qui lie leur nombre aux arêtes de bord (voir merge_triangles ci-dessous) reste intacte.

La paire, que le geste précédent ne peut pas atteindre

Deux nœuds intérieurs reliés par une arête, dont l’un au moins manque d’une maille, forment un motif qu’aucun des gestes ci-dessus ne prend. Abandonner l’un des deux tout seul ferait tomber deux de ses voisins de quatre à trois : un nœud irrégulier échangé contre deux, et l’effondrement le refuse à juste titre.

Pris ensemble, le calcul s’inverse. Leurs étoiles se recouvrent sur les mailles qui portent l’arête commune, si bien qu’à eux deux ils n’en portent que ( \mathrm{val}(a) + \mathrm{val}(b) - 2 ) — et la même identité ( 2q’ + t’ = n - 2 ) recoupe ce qui les borde :

( \mathrm{val}(a), \mathrm{val}(b) )bordavantaprèsmailles
3, 3hexagone4 quadrangles2 quadrangles4 → 2
3, 4heptagone4 quadrangles, 1 triangle2 quadrangles, 1 triangle5 → 3

Le geste abandonne donc deux nœuds et deux mailles d’un coup. Un triangle présent dans l’étoile est repris, jamais créé ni perdu : la parité qui lie leur nombre au bord interdit d’en faire apparaître un, et la redécoupe de l’heptagone réclame précisément celui qui était là.

L’arithmétique de valence le justifie : les quatre mailles offrent seize coins et les deux qui les remplacent en offrent huit ; la paire en emporte six avec elle, l’anneau en rend deux, et les deux erreurs de la paire sont annulées. Le gain est de +2 sur une paire ordinaire et monte à +4 quand l’anneau porte un nœud de valence 5 à dépenser.

La paire est examinée avant le nœud seul : c’est le motif le plus spécifique et la meilleure affaire. Dans l’autre ordre, l’effondrement d’un nœud prend l’un des deux et la paire n’a jamais sa chance — mesuré.

Comme tout geste qui supprime un nœud, il se juge après relaxation : la qualité mesurée juste après la découpe baisse (0,535 à 0,485 en médiane), et la lire là conduirait à refuser précisément les gestes qui paient.

Sur un maillage frais sorti d’un paveur, cleanup ne trouve rien à faire — le paveur passe la même main en fin de course. Son intérêt est ailleurs : un maillage lu depuis gmsh, ou sorti de merge_triangles.

Le sens de parcours n’a pas à vous préoccuper

Toutes les mesures de qualité employées ici sont signées : une maille lue en sens horaire compte négatif, ce qui se lit comme retournée. C’est volontaire — c’est ce signe qui empêche le lissage de retourner une maille — et un maillage entièrement horaire n’a pourtant rien d’anormal : un paveur rend le sens du contour qu’on lui a donné, si bien qu’un domaine maillé depuis un contour extérieur inversé (invert) sort horaire.

Les trois opérateurs remettent donc chaque maille en sens trigonométrique à la lecture et rendent le maillage dans le sens où il est entré. Rien à faire de votre côté, et orient n’est pas un préalable.

merge_triangles — les triangles, par paires

En comptant les côtés de chaque face, ( 4Q + 3T = 2E_\text{int} + E_\text{bord} ), donc ( T \equiv E_\text{bord} \pmod 2 ) : le nombre de triangles a la parité du nombre d’arêtes de bord, qui est intouchable. Aucune suite d’opérations locales ne peut la changer, et un maillage à nombre impair de triangles en garde un.

C’est aussi pourquoi « effondrer un triangle en un nœud » ne marche pas : fusionner ses trois coins en un seul coûte une arête à chaque quadrangle voisin, qui devient triangle à son tour. Gain net nul.

Trois gestes, tous purement topologiques — aucun nœud ne bouge, aucun n’est créé :

  1. Fusion. Deux triangles partageant une arête font un quadrangle. C’est à prendre ou à laisser : il n’y a qu’un quadrangle possible, et s’il sort rentrant la paire est bloquée.

  2. Regroupement. En prenant un quadrangle voisin avec eux, les trois mailles font un hexagone — 3 + 3 + 4 arêtes moins deux fois les deux partagées — qui se coupe en deux quadrangles selon l’une de ses trois grandes diagonales. Là où la fusion n’offre qu’un candidat — à prendre ou à laisser — celui-ci en offre trois, et c’est ce qui débloque le gros des paires. Prendre deux quadrangles plutôt qu’un couvre en plus la configuration en éventail autour d’un nœud, et en retire encore quelques-uns sur une ellipse.

    Trois quadrangles donneraient un octogone, qui veut trois quadrangles et bien plus de façons de le couper. La limite est mise à deux : le rendement s’essouffle, pas la recherche.

  3. Marche. Un triangle et un quadrangle voisin font un pentagone, qui se redécoupe en quadrangle + triangle de cinq façons : le triangle avance d’une maille et va chercher un partenaire plus loin.

Le contour du motif recoupé n’est jamais modifié, dans les trois cas : ces gestes ne peuvent pas changer le bord du maillage, par construction.

La validité ne se négocie pas, ici comme dans les paveurs : deux triangles dont l’union n’est pas convexe restent deux triangles. Rappelez l’opérateur après un lissage — une fusion refusée le devient souvent une fois les nœuds déplacés.

Ce que la chaîne donne

Cercle pavé par grid_surface, 1 236 mailles dont 8 triangles :

maillestrianglesqualité minp1p5
brut1 23680,3660,4710,800
après la chaîne1 21600,7810,8030,841

Les triangles tombent à zéro — une seule passe de merge_triangles y suffit sur ce cercle — et le pire quadrangle passe de 0,366 à 0,781. Chaque maillage intermédiaire reste valide, et le tour répété converge au lieu de dériver.

Un point à connaître : la sortie brute a la queue basse plus mince qu’avant que cleanup sache effondrer une étoile à trois mailles — premier centile à 0,471 contre 0,643. C’est le prix du geste, qui supprime un nœud et laisse ses voisins un peu étirés là où il passe, le temps que le lissage suivant les reprenne. Il est largement rendu : trois fois moins de triangles à la sortie du paveur, et aucun à l’arrivée.

Couche limite hexaédrique : pave_volume

pave_volume(envelope, layers=1, thickness=None, size=None) remplit la même enveloppe fermée que triangulate_volume, mais met des hexaèdres là où ils comptent — dans la couche contre le bord, où les gradients de contrainte et de flux sont les plus raides et où la forme d’une maille décide de la précision — et laisse l’intérieur, où le champ est lisse, aux tétraèdres.

peau (QUA4 / TRI3)  ──►  décalage intérieur  ──►  HEX8 / PENTA6   (couche limite)
                                                        │
                     faces internes carrées ──► PYRA5   │           (le raccord)
                                                        ▼
                                 vide borné par des triangles seulement
                                                        │
                                          triangulate_volume  ──►  TET4

Pourquoi les pyramides ne sont pas facultatives

Les faces internes de la couche sont carrées, et un tétraèdre n’en a aucune. Découper chaque carré en deux triangles ne suffit pas : l’hexaèdre de l’autre côté continue de voir une face carrée, avec un nœud suspendu en son milieu. Le maillage n’est plus conforme et aucun solveur ne peut assembler à travers cette face.

La pyramide est le seul élément qui présente un carré d’un côté et des triangles de l’autre — c’est exactement ce que réclame le raccord. Ses fonctions de forme se réduisent à celles d’un QUA4 sur la base et restent linéaires le long des arêtes vers le sommet, ce qui assure la continuité des deux côtés : voir PYRA5.

Une fois chaque face carrée coiffée, ce qui reste du vide n’est plus borné que par des triangles, et le mailleur tétraédrique existant prend le relais — en mode strict, donc en réutilisant ces triangles tels quels : les deux parties du maillage se rejoignent nœud à nœud.

Un front, pas un décalage global

Décaler l’enveloppe entière d’une même distance est la version naïve, et elle lâche dès que le solide cesse d’être épais partout : un seul étranglement plafonne l’épaisseur de toutes les couches, sur toute la pièce. Le front fait trois choses à la place.

Il avance de ce que la place permet. Le pas de chaque nœud est borné par la distance du front à lui-même en ce point — la moitié de la distance à la facette la plus proche à laquelle il n’appartient pas. Là où le solide est épais la couche est pleine, là où il se pince elle s’amincit au lieu que tout le maillage renonce. Demander une couche vingt fois plus épaisse que la pièce ne produit donc plus une erreur mais une couche adaptée.

Il place ses éléments localement. Une facette qui ne peut pas avancer — parce que la maille sortirait retournée — reste où elle est pendant que ses voisines poursuivent. La marche ainsi créée est refermée par une paroi latérale, un quadrangle neuf tendu entre l’ancienne arête et la nouvelle. Son orientation n’est pas choisie mais forcée : si A avance et que sa voisine B reste, l’arête (u,w) qu’elles partageaient n’est plus empruntée que par B, dans le sens (w,u) ; il faut donc que quelque chose l’emprunte en (u,w), et emprunte la nouvelle arête en (w',u'). Le quadrangle [u, w, w', u'] fait exactement les deux, et le front reste une surface fermée et orientée.

Il se coud. Deux parties du front qui se retrouvent à distance de contact sont soudées, ce qui referme une région mince au lieu d’y laisser un éclat qu’aucune maille ne peut remplir. Deux critères, tous deux nécessaires : les deux nœuds ne doivent pas partager de facette — les souder l’écraserait, ce n’est pas une couture mais une dégénérescence — et leurs normales doivent se faire face. Sans ce second critère, deux nœuds voisins d’une même nappe lisse se soudent et replient la surface ; c’est ce qui rendait le cube impossible à mailler pendant la mise au point.

Il est lissé. Les nœuds qu’il a créés sont relâchés sous garde de validité, si bien qu’un pas raccourci par la place disponible ne reste pas en coude. La garde est le jacobien normalisé lui-même, pas un indicateur qui lui ressemblerait, et le balayage est de Gauss–Seidel : chaque déplacement est jugé sur le maillage tel qu’il est, donc la pire maille ne peut que s’améliorer.

Décaler n’est pas « déplacer chaque nœud selon sa normale »

Moyenner les normales des facettes incidentes donne une direction, et cette direction ne suffit pas. Déplacez le coin d’un cube de \( t \) le long de la normale moyenne \( (1,1,1)/\sqrt3 \) et chacune des trois faces ne s’écarte que de \( t/\sqrt3 \) : la couche est la plus mince là où la géométrie tourne, c’est-à-dire là où elle peut le moins se le permettre. Pire, au coin d’un tétraèdre la normale moyenne est tangente à l’une des faces incidentes, qu’un déplacement le long d’elle ne décale donc pas du tout. Aucun facteur d’échelle ne rattrape cela : c’est la direction qui est fausse.

Le nœud décalé doit être le point où les facettes incidentes, chacune poussée de \( t \) vers l’intérieur, se rencontrent :

\[ \mathbf{d} \cdot \mathbf{n}_j = -t \quad \text{pour chaque facette incidente } j, \]

soit trois équations à trois inconnues à un coin, davantage sur une surface lisse, moins sur une arête. Résoudre au sens des moindres carrés, par les équations normales \( (N^{\!\top}\!N)\,\mathbf{d} = -t\,N^{\!\top}\mathbf{1} \), couvre les trois cas d’un coup et rend l’intersection exacte quand elle existe. Au coin du cube : \( \mathbf{d} = -t(1,1,1) \).

Convention

envelope est une surface fermée de facettes QUA4 et/ou TRI3, de normales sortantes de la matière — même convention que triangulate_volume, si bien que la peau d’un maillage (skin) s’y branche directement. Ses nœuds sont réutilisés tels quels. Une enveloppe ouverte, ou dont les facettes se contredisent sur l’orientation, est refusée en nommant l’arête fautive.

layers couches sont poussées vers l’intérieur, chacune de thickness de profondeur ; thickness=None prend la longueur d’arête moyenne de l’enveloppe, ce qui donne des mailles à peu près cubiques. size est la taille visée pour le cœur tétraédrique.

Le résultat porte un sous-maillage HEX8 (issu des facettes carrées), un PENTA6 (des triangulaires), un PYRA5 (le raccord) et un TET4 (le cœur), chacun présent seulement s’il n’est pas vide.

Exemple Python

import pyrucast as pc

peau = pc.mesh.skin(solide)  # QUA4, outward normals
maille = pc.mesh.pave_volume(peau, layers=1, thickness=0.15, size=0.4)
print(dict(zip(maille.element_types(), maille.cell_counts())))
# {'HEX8': 54, 'PYRA5': 54, 'TET4': 408}

Pièges

  • Orientation. Une enveloppe retournée est refusée en le disant ; pyrucast.mesh.invert la remet à l’endroit.
  • Épaisseur. Une couche plus épaisse que le solide ne peut pas rentrer : le décalage retourne l’enveloppe et l’opérateur sort en erreur en nommant la couche fautive.
  • Le cœur peut refuser. Le mailleur tétraédrique travaille en mode strict, donc sans ajouter de nœud sur la surface intérieure ; s’il n’y arrive pas, l’erreur le dit et une couche plus mince, ou une enveloppe plus fine, est la réponse habituelle.

Qualité et coût

Mesuré sur des cubes de 6³ à 16³ mailles de peau, en --release (cargo test --release -- --ignored volume_report --nocapture) :

casmaillestempsdébitjacobien médianinversées
cube 6³, 1 couche4 0540,22 s18 700 /s0,3740
cube 8³, 1 couche7 4220,38 s19 500 /s0,3450
cube 12³, 1 couche16 4540,80 s20 600 /s0,4180
cube 16³, 1 couche35 0151,72 s20 300 /s0,4310

Le coût est linéaire, autour de 20 000 mailles/s et 50 µs par maille. Par type, sur le cube 16³ :

typeminimummédiane
HEX80,5770,987
PYRA50,1810,319
TET40,0400,431

La couche hexaédrique est donc quasi parfaite — c’est le but — et le minimum de 0,577 n’est pas un défaut mais la valeur exacte d’un coin à 60°, celle des hexaèdres qui suivent une arête du cube. Les pyramides sont les mailles les plus médiocres du lot, ce qui est attendu d’un élément de raccord aplati.

Une seconde couche coûte cher : le cube 6³ tombe à 2 100 mailles/s, la tétraédrisation du cœur devenant nettement plus difficile.

Ce que l’enveloppe devient

Elle est respectée à la maille près. Ses nœuds sont réutilisés tels quels (mêmes identifiants, mêmes positions), ils sont marqués immobiles donc le lissage ne les touche pas, et chaque facette devient exactement une face de maille — un QUA4 la face d’un HEX8, un TRI3 celle d’un PENTA6. Aucun nœud n’est ajouté sur le bord. La couture protège explicitement ce contrat : entre deux nœuds candidats, celui de l’enveloppe est toujours le survivant, et si les deux en sont, la couture est refusée.

C’est aussi pour cela que le cœur tétraédrique tourne en mode strict : en mode permissif il ajouterait des nœuds sur la surface intérieure et ne rejoindrait plus les pyramides. Cette exigence est la contrepartie du contrat, et la principale source d’échec.

Limitations actuelles

  • Deux cas sur huit échouent, tous deux au cœur : une plaque mince et un barreau, c’est-à-dire les géométries où le front se referme réellement sur lui-même. La couture soude bien, mais un repli partiel laisse un vide que le mailleur tétraédrique, en mode strict, ne sait pas remplir. C’est la limite principale aujourd’hui.
  • La profondeur des pyramides est fixée au quart de l’arête de leur base. Plus profondes, elles seraient mieux formées mais finiraient par se traverser.
  • Les parois latérales ne servent jamais sur les cas mesurés. Le mécanisme est là et testé — retenir une facette lève bien quatre parois et le front reste fermé — mais sur les huit géométries du banc, le plafonnement par la place suffit toujours à rendre les mailles valides, et aucune facette n’a besoin d’être retenue. C’est une capacité en réserve, pas un moteur en service.
  • Le plastering complet — remplir tout le volume d’hexaèdres par front avançant, sans cœur tétraédrique — reste un problème ouvert. Sandia, qui l’a inventé, l’a abandonné : la fermeture du vide central bute sur des obstructions topologiques qu’une méthode locale ne voit pas. Le cœur tétraédrique n’est donc pas un raccourci mais l’état de l’art.

Mailleur volumique : triangulate_volume

solide = pyrucast.mesh.triangulate_volume(
    enveloppe, size=None, allow_surface_nodes=False
)

Le compagnon 3D de triangulate_surface : il remplit l’intérieur d’une enveloppe fermée en TRI3 avec des TET4. Les normales de l’enveloppe doivent pointer vers l’extérieur de la matière ; une forme concave est admise, et une cavité interne n’est qu’une autre surface fermée dont les normales pointent vers le trou — elle se soustrait d’elle-même, sans argument dédié.

peau = pyrucast.mesh.convert(pyrucast.mesh.skin(solide_penta6), "TRI3")
volume = pyrucast.mesh.triangulate_volume(peau, size=0.3)

L’enveloppe est respectée exactement : ses nœuds sont réutilisés tels quels (mêmes NodeId, mêmes positions), et aucun nœud n’est posé sur la surface — les nœuds ajoutés le sont strictement à l’intérieur. Ce n’est pas un effort au mieux : avant d’écrire quoi que ce soit, l’opérateur vérifie que le bord du maillage produit est exactement l’ensemble des facettes reçues.

Ce qui se passe, et pourquoi

Le mailleur enchaîne cinq étapes. Chacune répond à une difficulté précise, et il vaut la peine de savoir laquelle, parce que les messages d’erreur les nomment.

1. Prédicats exacts

Tout repose sur deux questions posées des millions de fois : de quel côté du plan (a, b, c) se trouve d ? et le point e est-il dans la sphère passant par a, b, c, d ? Ce sont les signes de deux déterminants :

\[ \mathrm{orient3d}(a,b,c,d)=\begin{vmatrix} b_x-a_x & b_y-a_y & b_z-a_z\\ c_x-a_x & c_y-a_y & c_z-a_z\\ d_x-a_x & d_y-a_y & d_z-a_z \end{vmatrix} \qquad \mathrm{insphere}(a,b,c,d,e)=-\begin{vmatrix} a_x-e_x & a_y-e_y & a_z-e_z & \lVert a-e\rVert^2\\ b_x-e_x & b_y-e_y & b_z-e_z & \lVert b-e\rVert^2\\ c_x-e_x & c_y-e_y & c_z-e_z & \lVert c-e\rVert^2\\ d_x-e_x & d_y-e_y & d_z-e_z & \lVert d-e\rVert^2 \end{vmatrix} \]

orient3d vaut six fois le volume signé, et il est positif exactement quand le tétraèdre (a,b,c,d) est bien orienté au sens de TET4 — face 0-1-2 vue en sens direct depuis le nœud 3.

Ce qui compte n’est pas la précision de ces valeurs mais leur cohérence : si orient3d(a,b,c,d) répond « au-dessus », alors orient3d(b,a,c,d) doit répondre « en dessous », et un point ne peut pas être à la fois dans un tétraèdre et hors de ses quatre faces. En f64 nu, près d’une dégénérescence, cette cohérence tombe — et une seule réponse contradictoire corrompt le graphe d’adjacence. C’est de cette façon qu’un mailleur incrémental tourne en boucle ou produit des cellules qui se recouvrent.

Ni une tolérance ni un jitter n’y remédient : ils rendent le prédicat généralement juste, pas cohérent avec lui-même. triangulate_volume calcule donc le signe exact, par la technique des expansions flottantes de Shewchuk : une estimation en f64 comparée à une borne d’erreur rigoureuse, puis, dans le seul cas où le signe reste indécidable, une réévaluation en arithmétique exacte. Un cube, une grille régulière, des coins cosphériques sont ainsi décidés et non devinés.

2. Triangulation de Delaunay des nœuds de l’enveloppe

Construite point par point selon Bowyer-Watson : on supprime tous les tétraèdres dont la sphère circonscrite contient le nouveau point p, puis on rebouche la cavité en joignant p à chaque face de son bord. L’appartenance à la cavité est insphere > 0 et rien d’autre, ce qui garantit que la cavité reste étoilée — visible en entier depuis p — et donc que le rebouchage produit des cellules bien formées.

Les points cosphériques ne sont pas perturbés : insphere == 0 signifie simplement « hors cavité ». On obtient l’une des triangulations de Delaunay valides de la configuration dégénérée, choisie de façon cohérente.

3. Récupération du bord

La triangulation précédente pave l’enveloppe convexe des nœuds et ne doit rien à la surface d’où ils viennent : une arête de l’enveloppe peut être traversée par un tétraèdre, une facette percée par une arête. Avant de pouvoir distinguer l’intérieur de l’extérieur, chacune doit apparaître dans la triangulation.

C’est la partie difficile, et pour une raison de fond : certains polyèdres n’admettent aucune tétraédrisation sur leurs propres sommets. Le prisme tordu de Schönhardt est l’exemple d’école. Plus près de vous : sur les 64 façons de trianguler le bord d’un cube, la plupart ne se remplissent pas. Ce n’est donc pas un algorithme perfectible, c’est une question dont la réponse est parfois « il n’y en a pas ».

La récupération procède obstacle par obstacle — bascules locales, puis reconstruction de la poche qui bloque — et, quand elle n’y arrive pas, elle nomme l’arête ou la facette en cause plutôt que de rendre un maillage qui ne correspond pas à la surface reçue.

Reconstruire une poche : deux remplisseurs. Le premier cherche, en faisant croître un pavage cellule par cellule depuis la surface de la poche. Il est complet — lui seul peut prouver qu’aucun remplissage n’existe — mais il est exponentiel et s’arrête à quelques cellules. Le second ne cherche pas : il calcule l’unique candidat canonique, la triangulation de Delaunay des sommets de la poche, et lui pose une seule question — contient-elle chaque face de la surface de la poche ? Si oui, les cellules qui tombent à l’intérieur pavent exactement la poche, pour le prix d’une triangulation quel que soit son volume ; si non, il nomme la face sur laquelle il a buté.

Ce nom est une instruction, pas un diagnostic : en absorbant la cellule située de l’autre côté de cette face, celle-ci cesse d’être sur la surface de la poche, et l’obstacle ne peut pas se représenter. On repose alors une question strictement plus grande, sans arbre de recherche ni retour arrière. C’est ce qui rend abordable la récupération d’une facette prise dans un plan interne — la diagonale d’un quadrilatère de paroi d’extrusion, par exemple — là où la recherche exhaustive renonçait. La facette à récupérer est passée comme un mur à deux faces : la poche est coupée en deux le long d’elle et chaque moitié est remplie depuis son propre côté, de sorte qu’une facette contenue dans la triangulation est une facette récupérée.

Ce que cela ne fait pas. Une triangulation de Delaunay est ce qu’elle est : on ne peut pas lui demander une arête. Or une arête d’enveloppe manquante est manquante précisément parce qu’elle n’est pas de Delaunay. La retriangulation de cavité traite donc les facettes, pas les arêtes ; une arête bloquée reste l’affaire des bascules, et si elles n’y suffisent pas, du mode allow_surface_nodes.

4. Séparation matière / vide

Les facettes de l’enveloppe deviennent des murs. Deux tétraèdres séparés par un mur sont de part et d’autre de la surface ; toute autre paire de voisins est du même côté. L’intérieur s’obtient donc par inondation depuis des cellules connues intérieures — et « connue intérieure » découle de l’orientation que vous avez fournie, la matière étant du côté opposé à la normale.

L’inondation est menée des deux côtés, et les deux résultats doivent partitionner le maillage : chaque cellule intérieure ou extérieure, aucune les deux, aucune ni l’une ni l’autre. Une cellule qui échappe à cela est une fuite, signalée et non maillée en silence.

5. Qualité : raffinement puis chasse aux slivers

Un maillage valide n’est pas un maillage utilisable. Une tétraédrisation des seuls nœuds d’une surface contient toujours des cellules dont les quatre coins sont presque coplanaires, et une poignée suffit à rendre une matrice élémentaire singulière.

Raffinement de Delaunay. On prend la cellule la plus mal formée et on pose un nœud au centre de sa sphère circonscrite. Deux critères décident :

  • le rapport rayon-arête \( \rho = R/\ell \), rayon circonscrit sur arête la plus courte. Un tétraèdre régulier vaut \( \rho = \sqrt6/4 \approx 0{,}61 \) ; une aiguille ou un coin en donne un grand. Découper au-dessus d’un seuil \( B \) termine pour tout \( B > 2 \) — d’où le seuil juste au-dessus de 2 ;
  • la taille, \( R \) comparé à size. Pour un tétraèdre régulier d’arête \( a \), \( R = a\sqrt6/4 \), ce qui convertit la longueur d’arête demandée en rayon visé.

Une règle est indispensable à la terminaison : l’empiètement. Un nœud posé dans la sphère d’une facette du bord dégrade les cellules contre cette facette au lieu de les améliorer, et le raffinement le redemande sans fin. La réponse classique est de découper la facette ; l’enveloppe ne nous appartenant pas, la cellule est simplement laissée telle quelle.

C’est aussi ce qui borne size par le bas : on ne peut pas mailler plus fin que la surface ne le permet. Sur un cube à huit coins, tout centre circonscrit empiète, et size n’a aucun effet ; il faut une enveloppe déjà discrétisée à la finesse voulue.

Chasse aux slivers. Le raffinement ne peut rien contre le sliver : quatre coins proches d’un même plan, répartis régulièrement sur un cercle. Son arête la plus courte est honorable et sa sphère circonscrite petite — \( \rho \) ne voit rien d’anormal — alors que son volume est presque nul. C’est un théorème, pas une lacune d’implémentation : aucun nœud inséré ne le casse. Le maillage est donc amélioré plutôt que subdivisé, par les deux seuls mouvements qui ne changent pas ce qu’il remplit :

  • reconnexion : les mêmes nœuds, joints autrement ;
  • retrait d’arête : les arêtes du sliver ôtées, une à une ;
  • relaxation : les mêmes liens, un nœud déplacé — les nœuds intérieurs seulement, jamais les vôtres.

Les trois sont jugés sur le plus petit angle dièdre des cellules touchées et appliqués seulement s’ils l’améliorent, ce qui rend la passe monotone.

Le deuxième est celui qui tue réellement les slivers, et la raison est géométrique. Un sliver est plat en travers d’une arête : une bascule 2-3 sur une de ses faces n’a donc souvent nulle part où aller — la paire de cellules n’y est pas convexe — tandis que vider l’anneau de cellules autour de l’arête fautive et le remplir autrement a toujours la place de le faire. Le retrait d’arête généralise la bascule 3-2 et tout ce qui vient après.

Mesuré sur 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) :

maillespart sous 10°part sous 1°cellule la plus plate / moyenne
28 0000,59 % → 0,00 %0 → 0\( 5{,}5\cdot10^{-2} \) → \( 1{,}5\cdot10^{-1} \)
117 0000,81 % → 0,02 %0,009 % → 0,000 %\( 2{,}9\cdot10^{-2} \) → \( 7{,}8\cdot10^{-2} \)
402 0000,94 % → 0,11 %0,022 % → 0,000 %\( 7{,}3\cdot10^{-5} \) → \( 3{,}6\cdot10^{-3} \)
977 0000,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 :

sizefacettestétraèdrestemps
0,013 30828 0811,9 s
0,0068 032117 2457,0 s
0,00417 144405 40024 s
0,00329 604984 41557 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 de tol ;
  • points_on_* — à moins de tol de la surface de la région, des deux côtés.

Deux formes sont fermées par des faces planes, et la distinction compte : points_on_cylinder et points_on_cone ne retiennent que la surface latérale, pas les disques d’extrémité — ceux-ci sont plats, c’est points_on_plane qui les coupe. Le tore, lui, est une surface fermée : la question ne se pose pas.

Le plan n’a pas de « dedans » : il a deux côtés, d’où points_below_plane, le demi-espace opposé à la normale (plan compris). Il n’existe pas de points_above_plane : retourner la normale donne l’autre moitié.

Enfin, la droite est infinie là où le cylindre est borné : pour une sélection le long d’un axe mais limitée au segment, c’est points_in_cylinder avec un petit rayon.

La tolérance tol

tol est la précision géométrique du test, mesurée comme une distance à la surface de la région. tol=None demande la valeur par défaut : 1e-6 × la diagonale de la boîte englobante du maillage. C’est ce qui rend les opérateurs sans échelle — le même appel marche sur une équerre en millimètres et sur un barrage en kilomètres — et ce qui rend points_on_plane utilisable sur des nœuds sortis d’un mailleur plutôt que d’une arithmétique exacte.

Pour le cône, la distance est prise perpendiculairement à la surface inclinée, et non radialement : la bande reste large de tol quelle que soit la pente.

Le cas « un seul nœud »

La requête du nœud le plus proche d’un point ne peut en renvoyer qu’un ; elle n’est donc pas dans cette famille et ne renvoie pas de POI1, mais un Node : c’est la méthode mesh.nearest_node([x, y]), des deux côtés — voir Opérateurs géométriques.

Repère de travail

Les nœuds sont testés dans les coordonnées où ils sont stockés. En axisymétrie, c’est le demi-plan méridien (r, z) et non le solide de révolution : une « sphère » y est un cercle du méridien. Le tore, qui a besoin d’un axe hors du plan pour être un tore, est 3D seulement.

import pyrucast

# A square plate meshed in TRI3.
plaque = pyrucast.mesh.triangulate_surface(contour, "TRI3", size=0.1)

# The left edge (x = 0): the plane of normal +x through the origin.
gauche = pyrucast.mesh.points_on_plane(plaque, [0.0, 0.0], [1.0, 0.0])

# The nodes of the fillet: inside the disc of radius 0.2 around the re-entrant corner.
conge = pyrucast.mesh.points_in_sphere(plaque, [1.0, 1.0], 0.2)

# The selection serves directly as the imposed support of a Dirichlet — the
# POI1 cloud is what `model.dirichlet` expects (cf. Constraints / Dirichlet).
mecanique = pyrucast.model.elasticity(
    pyrucast.FiniteElementSpace(plaque), "plane_stress"
)
blocage = pyrucast.model.dirichlet(
    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_nodes opère au sein d’une même Coords (l’invariant du Mesh impose déjà une Coords commune à tous les sous-maillages). Deux pièces maillées dans des Coords séparées ne se soudent donc pas : il faut d’abord les amener dans la même Coords.

Souder sur place : in_place=True

Par défaut merge_nodes copie : il rend un maillage neuf et laisse ses entrées intactes. Les maillages d’origine, eux, gardent donc leurs nœuds dupliqués — ce qui oblige à ne plus manipuler qu’un troisième maillage, et à recâbler tout ce qui pointait vers les deux premiers.

merge_nodes(mesh, tol, in_place=True) réécrit à la place la connectivité des sous-maillages existants — effet de bord assumé et voulu — et renvoie le maillage lui-même. Comme l’union mesh_a | mesh_b partage les sous-maillages (elle ne les copie pas), souder l’union soude du même coup mesh_a et mesh_b :

gauche = pyrucast.mesh.line(a, b, 4)
droite = pyrucast.mesh.line(b2, d, 4)  # b2 colocated with b, but distinct

pyrucast.mesh.merge_nodes(gauche | droite, 1e-6, in_place=True)

# The two pieces now really share the interface node.
assert droite.node(0, 0, 0).id == b.id

Ce que la mutation ne touche pas : la structure du maillage. Mêmes sous-maillages, mêmes types, même nombre de cellules dans le même ordre — seul quel nœud une cellule référence change. C’est ce qui rend l’effet de bord tenable : tout indice déjà détenu sur ces sous-maillages (numéros de cellules, et donc les champs par élément qui s’appuient dessus) reste valide. Les coordonnées des nœuds ne bougent pas non plus.

D’où deux refus, vérifiés sur tout le maillage avant la moindre écriture (un appel rejeté ne modifie donc rien) :

  • une cellule qui s’effondrerait est une erreur, là où la variante copiante l’abandonne : l’abandonner changerait le nombre de mailles, c’est-à- dire précisément l’invariant sur lequel repose l’appel sur place. Baissez tol, ou passez par la variante copiante ;
  • un sous-maillage scellé est une erreur : un espace d’éléments finis, une matrice — ou un champ, si c’est un nuage POI1 qui lui sert de support — l’a capturé et lit sa numérotation de nœuds. Soudez avant de les construire (ou repartez d’un duplicate()).

Les caches dérivés de la connectivité (index des nœuds, compagnon POI1 de to_poi1) sont invalidés par la réécriture, et les refcounts suivent : chaque emplacement réécrit incrémente son nouveau nœud et décrémente l’ancien.

Le retour est exactement le maillage passé — les mêmes sous-maillages, dont l’intérieur a changé —, pas une copie : en Python, out is mesh. On peut donc l’ignorer, ou chaîner dessus, au choix.

Côté Rust, c’est le même opérateur avec le même drapeau : ops::mesh::merge_nodes(&mesh, tol, in_place) — miroir strict de la forme Python, qui n’ajoute que la valeur par défaut. La brique de conteneur sous-jacente est SubMesh::remap_nodes(&map), un renommage de nœuds à structure constante.

Lecture d’un maillage gmsh : read_gmsh

read_gmsh importe un maillage produit par gmsh (fichier .msh, versions MSH 2.2 et MSH 4.1, en ASCII comme en binaire — l’endianness est lue dans le fichier). L’appelant fournit la Coords dans laquelle lire (il garde ainsi la main sur les nœuds) ; le résultat est un dict Python qui associe à chaque groupe physique son Mesh :

import pyrucast

coords = pyrucast.Coords(dim=2)
regions = pyrucast.mesh.read_gmsh(coords, "piece.msh")
# {'plate': Mesh<…>, 'bottom': Mesh<…>, …}  — order of the file preserved

plate = regions["plate"]
print(plate.element_types())  # e.g. ['TRI3']
print(plate.cell_count())

Groupes, types et Coords partagée

  • Un Mesh par groupe physique, et un sous-maillage par type d’élément à l’intérieur de chaque groupe. Les éléments sans groupe physique sont rangés sous la clé "<ungrouped>".

  • Tous les Mesh partagent la Coords fournie : un nœud à la frontière de deux groupes (p. ex. un nœud du bord "bottom" qui appartient aussi à la surface "plate") est le même nœud des deux côtés — pas un doublon. Comme c’est votre Coords, vous gardez le handle pour poser des conditions aux limites sur une région nommée lue dans le fichier :

    coords = pyrucast.Coords(dim=2)
    regions = pyrucast.mesh.read_gmsh(coords, "piece.msh")
    plate = regions["plate"]
    bottom = regions["bottom"]  # même Coords que plate
    
    fes = pyrucast.FiniteElementSpace(plate)
    # ... assemblage sur 'plate', blocage des nœuds de 'bottom', etc.
    

    La Coords peut déjà contenir de la géométrie : l’import s’y ajoute. Si vous égarez le handle côté Python, mesh.coords() le récupère depuis n’importe quel Mesh du dict.

Types d’éléments reconnus

Les codes gmsh sont traduits vers les types pyrucast ; l’ordre local des nœuds coïncide déjà avec le repère de référence, la connectivité est donc copiée telle quelle.

Code gmshType pyrucast
1SEG2
2TRI3
3QUA4
4TET4
5HEX8
6PENTA6
7PYRA5
15POI1
8SEG3
9TRI6
16QUA8
10QUA9
11TET10
17HEX20
18PENTA15
12HEX27

Pour les types quadratiques volumiques (TET10, HEX20, PENTA15, HEX27), gmsh numérote les nœuds de milieu d’arête (et de face pour HEX27) dans un ordre différent de la convention pyrucast (VTK) : la connectivité est réalignée à la lecture (même permutation que meshio). Tout autre type gmsh (ordre 3 et plus…) lève une erreur explicite.

Dimension

gmsh stocke toujours trois coordonnées par nœud ; c’est la dimension de la Coords fournie qui décide combien sont conservées. On lit donc dans une Coords(dim=2) pour aplatir un maillage planaire sur xy, ou dans une Coords(dim=3) pour garder le relief.

Seuls les nœuds référencés par un élément sont matérialisés dans la Coords ; les nœuds isolés listés mais utilisés par aucun élément sont ignorés.

read_gmsh_str(coords, text) fait la même chose à partir du texte du fichier déjà chargé en mémoire (utile pour les tests ou un .msh reçu sur le réseau). Côté Rust, ops::mesh::read_gmsh(coords, path) et ops::mesh::read_gmsh_str(coords, text) renvoient un Vec<(String, Mesh)> ordonné.

Déléguer le maillage à gmsh : from_gmsh

Les mailleurs de pyrucast couvrent le cas courant ; les géométries difficiles, elles, se délèguent. On définit la géométrie et on maille dans gmsh — c’est son métier —, puis on appelle from_gmsh pour récupérer le résultat, sans passer par un fichier :

import pyrucast

# — la géométrie et le maillage restent l'affaire de gmsh —
gmsh.model.occ.addBox(0, 0, 0, 1, 1, 1)
gmsh.model.occ.synchronize()
gmsh.model.addPhysicalGroup(2, [1], name="encastrement")
gmsh.model.addPhysicalGroup(3, [1], name="piece")
gmsh.model.mesh.generate(3)

# — pyrucast comes and fetches the result, without going through a file —
coords = pyrucast.Coords(dim=3)
regions, _ = pyrucast.mesh.from_gmsh(coords)

piece = regions["piece"]
print(piece.element_types())  # ['TET4']
print(regions["encastrement"].element_types())  # ['TRI3']
print(coords.node_count())  # the model's nodes, shared by both

gmsh.finalize()  # pyrucast owns its data: the mesh outlives it

pyrucast lit gmsh, il ne le pilote pas : aucune fonction pyrucast ne crée de géométrie ni ne lance de maillage. from_gmsh rend un couple (maillages, champs). Les maillages forment le même dict[str, Mesh] que read_gmsh, avec les mêmes règles — un Mesh par groupe physique, une zone par type d’élément, une seule Coords partagée dont la dimension décide combien des trois coordonnées de gmsh sont gardées, et "<ungrouped>" pour le reste.

Les surfaces et les points nommés dans gmsh (addPhysicalGroup(…, name=…)) deviennent donc les clés du dictionnaire, et c’est sur elles qu’on pose ensuite les conditions aux limites. gmsh maille ses entités ponctuelles : un point nommé arrive comme un Mesh POI1.

Les vues gmsh deviennent des champs

Les vues du modèle (post-traitement gmsh, gmsh.view.addModelData) sont lues aussi, sauf si l’on passe views=False :

vue gmshchamp pyrucast
NodeDataNodeField
ElementDataElementField à un point par maille, avec une zone sur chaque groupe dont il définit toutes les mailles
plusieurs pas de tempsEvolution sur les temps de la vue

Une vue scalaire garde son nom comme nom de composante ; une vue à n composantes les nomme nom_0 … nom_{n-1}. Les autres sortes de vues (ElementNodeData, vues « liste ») sont ignorées avec un avertissement.

Ce qui est copié, et ce qui ne l’est pas

La chaîne a trois maillons, dont deux sont gratuits :

mailloncopie ?pourquoi
gmsh → numpynonles tableaux que rend l’API gmsh sont des vues sur la mémoire de gmsh, libérées au ramasse-miettes du tableau
numpy → pyrucastnonlecture par le protocole tampon — d’où le plancher Python 3.11 du projet, PyObject_GetBuffer n’étant entré dans l’API limitée qu’à cette version. Les étiquettes uint64 de gmsh comme int64 de medcoupling sont empruntées telles quelles
construction du maillageoui, une passeune Coords et un sous-maillage possèdent leurs tableaux : les coordonnées sont écrites directement dans la Coords, chaque connectivité est allouée une fois à sa taille et réécrite sur place dans l’ordre pyrucast

Autrement dit : une vue jusqu’à la frontière Rust, puis une seule passe. Rien n’est jamais matérialisé élément par élément côté Python. Une fois l’appel rendu, pyrucast possède ses données — gmsh.finalize() ne les emporte pas.

Un objet sans tampon à prêter — une list, ce que gmsh rend quand numpy n’est pas installé — est lu par la conversion ordinaire. Le résultat est le même, au prix d’une copie de plus.

Restreindre l’import

dim limite l’import à une dimension, tag à une seule entité de cette dimension (from_gmsh(coords, dim=2, tag=1)). La table des nœuds est lue en entier quoi qu’il arrive — une maille de surface s’appuie sur des nœuds classés sur ses courbes de bord — et seuls les nœuds référencés sont matérialisés.

Une nuance à connaître pour le contrôle croisé : gmsh.write() n’écrit par défaut que les éléments portant un groupe physique, alors que from_gmsh voit tout le modèle. Le .msh relu peut donc contenir moins que l’import direct — la différence tient dans "<ungrouped>".

Lire un fichier MED : from_medcoupling

Le format MED est celui de Salome et de code_aster. pyrucast le lit et l’écrit au travers de medcoupling, la bibliothèque MED de Salome (pip install medcoupling). Elle n’est pas une dépendance de pyrucast : import pyrucast ne la charge pas, seul l’appel de from_medcoupling (ou de to_medcoupling) l’importe.

coords = pyrucast.Coords(dim=2)
regions, champs = pyrucast.mesh.from_medcoupling(coords, "plaque.med")
print(sorted(regions))  # ['bas', 'plaque'] — one Mesh per MED group
print(type(champs["T"]).__name__)  # Evolution: two time steps
print(champs["T"].shared_abscissas())  # [0.0, 60.0]
print(type(champs["sxx"]).__name__)  # ElementField, on pyrucast's Gauss points

La source est un chemin vers un .med, un medcoupling.MEDFileData ou un medcoupling.MEDFileUMesh (maillage seul) ; mesh_name choisit un maillage du fichier (par défaut le premier). Le résultat est un couple (maillages, champs) :

  • un Mesh par groupe MED — groupes de mailles de tous les niveaux, et groupes de nœuds sous forme de Mesh POI1 —, tous sur la Coords fournie, les mailles sans groupe sous "<ungrouped>". Les familles MED, qui codent l’appartenance aux groupes, sont traduites une fois par famille et par type, jamais maille par maille ;
  • les champs, sous leur nom MED :
champ MEDchamp pyrucast
ON_NODESNodeField sur les nœuds définis (profil compris)
ON_CELLSElementField à un point par maille
ON_GAUSS_PTElementField sur les points de Gauss de pyrucast — si la règle MED est la même, sinon une erreur explicite
plusieurs pas de tempsEvolution sur les temps du champ

Un champ aux mailles reçoit une zone sur chaque sous-maillage de groupe dont il définit toutes les mailles. Les autres discrétisations (ON_GAUSS_NE…) sont ignorées avec un avertissement.

La numérotation MED des nœuds dans une maille diffère de celle de pyrucast (qui est celle de VTK) pour les volumes : MED parcourt la première face dans l’autre sens. La permutation est une propriété de l’élément (ElementKind::med_permutation) et s’applique en Rust, pendant la passe de construction. Elle est vérifiée contre medcoupling lui-même : pour chacun des quinze types, la maille de référence écrite en MED a son volume positif et ses nœuds milieux au milieu des arêtes que medcoupling en déduit.

medcoupling n’est publié que pour Linux x86_64 et Windows (CPython 3.9 à 3.13) : pas de macOS ni d’ARM. Sa licence est la LGPL, ce qui convient à une dépendance facultative importée à l’exécution.

L’opérateur en dessous : from_arrays

from_gmsh et from_medcoupling sont les seules fonctions de pyrucast.mesh écrites en Python : elles ont besoin d’un interpréteur portant le module gmsh ou medcoupling, ce que Rust ne peut pas avoir. Elles ne font qu’aller chercher des tableaux et les passer à from_arrays, l’import générique que partagent tous les formats — utilisable directement quand on tient déjà les tableaux :

# The same square, but as flat arrays already in memory: the tags of the
# nodes, their coordinates, then one block per element type and group, whose
# connectivity is flattened. This is what `from_gmsh` and `from_medcoupling`
# hand over.
tags = [1, 2, 3, 4]
xyz = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 0.0]
blocs = [
    ("SEG2", [1, 2], ["bottom"]),
    ("TRI3", [1, 2, 3, 1, 3, 4], ["plate"]),
]

coords = pyrucast.Coords(dim=2)
regions, _, _ = pyrucast.mesh.from_arrays(coords, tags, xyz, blocs)
print(regions["plate"].element_types())  # ['TRI3']
print(coords.node_count())  # 4 — a single Coords for both groups

Le format est commun à tous les échanges :

  • nœuds — une étiquette entière par nœud (uint64 ou int64) et 1 à 3 coordonnées par nœud ;
  • blocs — (type, connectivité, étiquettes de mailles, groupes) : un bloc réunit un type d’élément et une combinaison de groupes, soit exactement une famille MED ou une entité gmsh. Les étiquettes de mailles ne servent qu’aux champs aux mailles (une séquence vide sinon, ou un triplet sans elles) ;
  • champs — node_fields=[(composantes, étiquettes de nœuds, valeurs)], cell_fields=[(composantes, étiquettes de mailles, valeurs, disposition)], la disposition valant "cell" ou une liste de règles de Gauss (type, nœuds de référence, points, poids) ;
  • ordre — order="pyrucast", "gmsh" ou "med" : la numérotation des nœuds dans une maille, réalignée en Rust.

from_arrays rend (maillages, champs aux nœuds, champs aux éléments). Le fichier .msh passe par le même moteur : le regroupement, le partage des nœuds et les permutations ne sont écrits qu’une fois, et un test compare maille pour maille ce que le fichier et la mémoire rendent de la même géométrie.

Côté Rust, ops::mesh::from_arrays(coords, node_tags, node_coords, blocks, node_values, cell_values, order) prend des CellBlock, NodeValues et CellValues qui ne font qu’emprunter les tableaux, et rend un Imported. Pour un maillage de 884 000 hexaèdres, la construction prend 79 ms contre 498 ms pour l’ancien import gmsh (cargo bench --bench mesh -- from_arrays) : plus de table de hachage par nœud, plus d’allocation ni de nom de groupe par maille.

Le chemin inverse : to_arrays

pyrucast.export.to_arrays rend la même forme, à partir de maillages et de champs pyrucast — c’est la sortie que partagent l’export VTK, to_gmsh et to_medcoupling :

# The way back: the same flat shape, ready for any other tool.
sortie = pyrucast.export.to_arrays(regions)
print(list(sortie["node_tags"]))  # [1, 2, 3, 4]
print(sortie["blocks"][1][0], list(sortie["blocks"][1][1]))  # TRI3 [1, 2, 3, 1, 3, 4]
# Each array is a read-only pyrucast.Array: numpy.asarray(...) or
# memoryview(...) read it without a copy; tolist() copies it into Python.
tags = sortie["node_tags"]
print(isinstance(tags, pyrucast.Array), tags.format)  # True l — int64
print(tags.tolist())  # [1, 2, 3, 4]
  • les nœuds sont numérotés dans l’ordre de première apparition, à partir de first_tag (1 par défaut, 0 pour medcoupling) ;
  • une maille présente dans plusieurs groupes — ce que produit tout import gmsh ou MED — n’est écrite qu’une fois, dans un bloc qui porte tous ses groupes ;
  • un champ aux nœuds donne une ligne par nœud, 0 là où il ne définit rien ; un champ aux éléments donne la moyenne de ses points de Gauss par maille, ou avec gauss=True ses valeurs brutes et la règle de chaque type ;
  • chaque tableau est un pyrucast.Array, en lecture seule, que numpy.asarray ou memoryview lisent sans copie — sans que numpy soit une dépendance de pyrucast.

Deux traductions servent aux adaptateurs et restent utilisables seules : le type pyrucast d’un code gmsh, et la règle de Gauss de pyrucast exprimée dans l’élément de référence d’un autre format (et l’appariement inverse, qui refuse une règle différente) :

# The translations the exchange adapters rely on.
print(pyrucast.mesh.element_type_from_gmsh(4))  # TET4 — gmsh code 4
# pyrucast's Gauss rule of TRI3, in a reference triangle twice as large:
refs = [0.0, 0.0, 2.0, 0.0, 0.0, 2.0]
xi, w = pyrucast.mesh.gauss_to_external("TRI3", refs, "pyrucast")
print(len(w))  # 3 points, weights four times heavier
# ...and back: which external point is each of pyrucast's points.
print(pyrucast.mesh.match_gauss("TRI3", refs, xi, w, "pyrucast"))  # [0, 1, 2]

Triangulation : briques mathématiques

Ce chapitre rassemble les fondements mathématiques utilisés par triangulate_surface et le module pyrucast::ops::mesh::triangulation. Toutes les formules sont écrites avec la convention de pyrucast : points 2D notés \( P = (x, y) \), points 3D notés \( P = (x, y, z) \), vecteurs en gras, produit scalaire \( \cdot \), produit vectoriel \( \times \).

Aire signée d’un polygone 2D (formule du lacet)

Soit \( P_0, P_1, \dots, P_{n-1} \) les sommets d’un polygone simple fermé (l’arête \( P_{n-1} P_0 \) ferme la boucle implicitement). Son aire signée est

\[ A = \frac{1}{2} \sum_{i=0}^{n-1} \left( x_i\, y_{i+1} - x_{i+1}\, y_i \right) \]

avec la convention d’indices modulo \(n\). Le signe de \(A\) encode l’orientation :

  • \( A > 0 \) : polygone parcouru en sens trigonométrique (CCW) ;
  • \( A < 0 \) : sens horaire (CW) ;
  • \( A \approx 0 \) : polygone dégénéré (sommets colinéaires).

Implémentation : signed_area dans src/ops/mesh/triangulation.rs.

use pyrucast::atoms::Point2;
use pyrucast::ops::mesh::triangulation::signed_area;

#[test]
fn l_aire_signee_donne_le_sens_de_parcours() {
    // Carré unitaire CCW — aire = +1.
    let pts = vec![
        Point2::new(0.0, 0.0),
        Point2::new(1.0, 0.0),
        Point2::new(1.0, 1.0),
        Point2::new(0.0, 1.0),
    ];
    assert!((signed_area(&pts) - 1.0).abs() < 1e-12);

    // The same square CW — area = -1.
    let pts_cw: Vec<_> = pts.iter().cloned().rev().collect();
    assert!((signed_area(&pts_cw) + 1.0).abs() < 1e-12);
}

signed_area n’est pas exposée en Python : elle est utilisée en interne par triangulate_surface. Pour obtenir l’aire d’un maillage depuis Python, calculez-la à partir des coordonnées des triangles.

Ear clipping : usage direct

La fonction ear_clip_2d est utilisable indépendamment de triangulate_surface sur n’importe quel polygone 2D simple :

use pyrucast::ops::mesh::triangulation::ear_clip_2d;

#[test]
fn le_decoupage_par_oreilles_rend_n_moins_2_triangles() {
    // Pentagone CCW quelconque.
    let pts = vec![
        Point2::new(0.0, 0.0),
        Point2::new(2.0, 0.0),
        Point2::new(2.5, 1.5),
        Point2::new(1.0, 2.5),
        Point2::new(-0.5, 1.5),
    ];
    let triangles = ear_clip_2d(&pts).unwrap();
    // n - 2 = 3 triangles, indices into pts.
    assert_eq!(triangles.len(), 3);

    // Checking that a triangle is CCW (signed area > 0).
    for [i, j, k] in &triangles {
        let area = signed_area(&[pts[*i], pts[*j], pts[*k]]);
        assert!(area > 0.0, "triangle non-CCW détecté");
    }
}

Test d’oreille (ear clipping)

Pour un polygone simple CCW à \(n\) sommets, un sommet \(P_i\) est une oreille si :

  1. Convexité locale en \(P_i\) — le triangle \((P_{i-1}, P_i, P_{i+1})\) est CCW :

    \[ (P_i - P_{i-1}) \times (P_{i+1} - P_i) > 0 \]

    (produit en croix 2D \( \mathbf{a} \times \mathbf{b} = a_x b_y - a_y b_x \)).

  2. Cavité vide — aucun autre sommet du polygone n’est dans le triangle fermé \((P_{i-1}, P_i, P_{i+1})\).

L’algorithme retire itérativement une oreille (créant un triangle de sortie et un polygone à \(n-1\) sommets) jusqu’à ne plus avoir que 3 sommets. Au total \(n - 2\) triangles sont produits.

L’orientation est détectée d’abord via \(A\) ; si \(A < 0\), on parcourt les indices à l’envers pour ramener à du CCW.

Plan moyen et base locale : usage direct

use pyrucast::atoms::Point3;
use pyrucast::ops::mesh::triangulation::{in_plane_basis, newell_normal};

#[test]
fn un_contour_3d_planaire_se_ramene_a_un_repere_local() {
    // A triangle in the y = 0 plane (the xz plane).
    let pts = vec![
        Point3::new(0.0, 0.0, 0.0),
        Point3::new(1.0, 0.0, 0.0),
        Point3::new(0.5, 0.0, 1.0),
    ];

    let normal = newell_normal(&pts).unwrap();
    // Normale attendue : (0, -1, 0) ou (0, 1, 0) selon le sens.
    assert!(normal.y.abs() > 0.99);

    let (u, v) = in_plane_basis(normal);
    // u and v are orthogonal to each other and to the normal.
    assert!(u.dot(&v).abs() < 1e-12);
    assert!(u.dot(&normal).abs() < 1e-12);

    // Projecting a point into the local frame (u, v).
    let origin = Point3::new(0.0, 0.0, 0.0);
    let p = Point3::new(0.5, 0.0, 0.5);
    let pu = (p - origin).dot(&u);
    let pv = (p - origin).dot(&v);
    println!("({pu:.3}, {pv:.3})");
}

newell_normal et in_plane_basis ne sont pas exposées en Python. Elles sont utilisées en interne par triangulate_surface pour les configurations 3D.

Plan moyen d’un polygone 3D : méthode de Newell

Soit un polygone à \(n\) sommets \(P_0, \dots, P_{n-1}\) approximativement coplanaires. La normale de Newell est obtenue par somme signée d’arêtes consécutives :

\[ \vec{n} = \sum_{i=0}^{n-1} \begin{pmatrix} (y_i - y_{i+1})(z_i + z_{i+1}) \\ (z_i - z_{i+1})(x_i + x_{i+1}) \\ (x_i - x_{i+1})(y_i + y_{i+1}) \end{pmatrix} \]

Propriétés clefs :

  • chaque composante de \(\vec{n}\) vaut 2 × l’aire signée du polygone projeté sur le plan coordonné correspondant (lacet généralisé en 3D) ;
  • \(\vec{n}\) est invariant par translation des sommets ;
  • la direction de \(\vec{n}/|\vec{n}|\) suit la règle de la main droite par rapport au sens de parcours.

Si \(|\vec{n}| \approx 0\), le polygone est dégénéré (colinéaire ou auto-recouvrant) — newell_normal renvoie alors None.

Base orthonormée du plan (Gram-Schmidt)

Étant donnée la normale unitaire \(\hat{n}\), on construit une base directe \((\vec{u}, \vec{v}, \hat{n})\) :

  1. On choisit l’axe canonique \(\vec{e}\) le moins aligné avec \(\hat{n}\) (composante absolue minimale).

  2. On orthogonalise par Gram-Schmidt :

    \[ \vec{u}’ = \vec{e} - (\vec{e} \cdot \hat{n})\, \hat{n}, \qquad \vec{u} = \frac{\vec{u}‘}{|\vec{u}’|} \]

  3. On complète :

    \[ \vec{v} = \hat{n} \times \vec{u} \]

Par construction \( |\vec{u}| = |\vec{v}| = 1 \), \( \vec{u} \cdot \vec{v} = \vec{u} \cdot \hat{n} = \vec{v} \cdot \hat{n} = 0 \), et \( \vec{u} \times \vec{v} = \hat{n} \) (triedre direct).

Projection orthogonale et critère de planéité

Un point 3D \(P\) est projeté dans le plan local d’origine \(O\) (centroïde du contour) par :

\[ p_u = (P - O) \cdot \vec{u}, \qquad p_v = (P - O) \cdot \vec{v} \]

La distance algébrique de \(P\) au plan est

\[ d = (P - O) \cdot \hat{n} \]

Le contour est jugé « plan » si

\[ \max_i |d_i| \le \varepsilon \cdot \mathrm{diag}, \qquad \varepsilon = 10^{-6} \]

où diag est la longueur de la diagonale de la AABB de l’ensemble des sommets. La tolérance relative \(10^{-6}\) tolère le bruit numérique sans laisser passer une vraie courbure 3D.

Pipeline Delaunay et CDT : usage direct en Rust

Les fonctions du module pyrucast::ops::mesh::triangulation sont utilisables indépendamment du système Mesh.

Delaunay pur

use pyrucast::ops::mesh::triangulation::delaunay_2d;

#[test]
fn delaunay_maille_un_nuage_de_points() {
    let pts = vec![
        Point2::new(0.0, 0.0),
        Point2::new(3.0, 0.0),
        Point2::new(3.0, 3.0),
        Point2::new(0.0, 3.0),
        Point2::new(1.5, 1.5), // point intérieur
    ];
    let triangles = delaunay_2d(&pts).unwrap();
    // 4 points = 2 triangles Delaunay ; le 5e point intérieur en ajoute d'autres.
    println!("{} triangles", triangles.len());
    assert!(triangles.len() >= 2);
}

Delaunay contraint (CDT) avec trous

use pyrucast::ops::mesh::triangulation::triangulate_polygon_with_holes;

#[test]
fn un_polygone_troue_se_triangule_directement() {
    // Contour extérieur : carré 4×4.
    let outer = vec![
        Point2::new(0.0, 0.0),
        Point2::new(4.0, 0.0),
        Point2::new(4.0, 4.0),
        Point2::new(0.0, 4.0),
    ];
    // Trou : carré 2×2 centré.
    let hole = vec![
        Point2::new(1.0, 1.0),
        Point2::new(3.0, 1.0),
        Point2::new(3.0, 3.0),
        Point2::new(1.0, 3.0),
    ];
    let triangles = triangulate_polygon_with_holes(&outer, &[hole]).unwrap();
    // Area = 16 - 4 = 12; without Steiner: 6 raw triangles.
    println!("{} triangles", triangles.len());
    assert!(!triangles.is_empty());
}

CDT avec raffinement de Ruppert

use pyrucast::ops::mesh::triangulation::{
    triangulate_polygon_with_holes_refined, RefinementOptions,
};

#[test]
fn le_raffinement_de_ruppert_insere_des_points_de_steiner() {
    let outer = vec![
        Point2::new(0.0, 0.0),
        Point2::new(4.0, 0.0),
        Point2::new(4.0, 4.0),
        Point2::new(0.0, 4.0),
    ];
    let opts = RefinementOptions {
        max_edge_length: Some(1.0),
        min_angle_deg: Some(20.0),
    };
    // The refinement inserts Steiner points: the function therefore returns
    // **the points** (input + Steiner) *and* the triangles indexing them.
    let (points, triangles) = triangulate_polygon_with_holes_refined(&outer, &[], opts).unwrap();
    println!(
        "{} triangles après raffinement, {} points",
        triangles.len(),
        points.len()
    );
    assert!(points.len() > outer.len());
}

Ces fonctions renvoient des indices dans le tableau de points fourni en entrée (plus les Steiner éventuels). Elles ne touchent pas à la Coords — triangulate_surface se charge de la conversion vers les NodeId.

Triangulation de Delaunay : la propriété du cercle vide

Une triangulation \(T\) d’un nuage de points \({P_0, \dots, P_{n-1}}\) est dite de Delaunay si, pour tout triangle \((P_a, P_b, P_c) \in T\), le disque circonscrit ne contient strictement aucun autre point du nuage.

Cette propriété équivaut à maximiser l’angle minimum sur l’ensemble des triangulations possibles — c’est pourquoi Delaunay est la triangulation de référence pour le FEM : elle évite naturellement les triangles très allongés.

Test in-circumcircle

Pour un triangle \((A, B, C)\) orienté CCW, le point \(D\) est strictement dans son disque circonscrit si et seulement si

\[ \det\! \begin{pmatrix} a_x - d_x & a_y - d_y & (a_x - d_x)^2 + (a_y - d_y)^2 \\ b_x - d_x & b_y - d_y & (b_x - d_x)^2 + (b_y - d_y)^2 \\ c_x - d_x & c_y - d_y & (c_x - d_x)^2 + (c_y - d_y)^2 \end{pmatrix} > 0 \]

Si le déterminant vaut zéro, les 4 points sont cocirculaires (cas dégénéré) ; s’il est négatif, \(D\) est strictement à l’extérieur.

C’est ce prédicat (sans normalisation, calculé en f64) que pyrucast utilise dans Bowyer-Watson.

Bowyer-Watson : insertion incrémentale

Pour insérer un nouveau point \(p\) dans une triangulation de Delaunay :

  1. On identifie l’ensemble bad des triangles dont le cercle circonscrit contient \(p\) (déterminant ci-dessus > 0).
  2. Leur union forme un polygone étoilé autour de \(p\) (théorème de Bowyer 1981 et Watson 1981).
  3. On retire ces triangles ; le bord de la cavité est un polygone simple.
  4. On retriangule en éventail depuis \(p\) : pour chaque arête \((u, v)\) de la cavité, on crée le triangle \((u, v, p)\).

L’invariant Delaunay est préservé par construction : les nouveaux triangles ne peuvent pas avoir d’autre point dans leur cercle circonscrit, sinon ce triangle aurait été dans la cavité.

Initialisation : pyrucast utilise un super-triangle englobant largement la AABB du nuage de points, ce qui garantit que tout point inséré tombera dans au moins un bad triangle. Les triangles touchant le super-triangle sont retirés à la fin.

Triangulation contrainte (CDT) : forcer des arêtes

Une CDT étend Delaunay avec des arêtes imposées (typiquement les arêtes du contour à mailler). La propriété du cercle vide n’est plus exigée à travers ces arêtes contraintes.

Pour forcer une arête \((a, b)\) absente :

  1. Identifier tous les triangles strictement traversés par le segment \((a, b)\). On suit une « marche » : on part d’un triangle contenant \(a\) ; on traverse en suivant les arêtes coupées.
  2. Retirer ces triangles ; le bord de la cavité forme deux polygones simples, l’un à gauche, l’autre à droite de l’arête \((a, b)\). On les sépare via le signe du produit en croix.
  3. Retrianguler chaque polygone par ear clipping.

Identification intérieur / extérieur : flood-fill par parité

Théorème de Jordan : tout polygone simple fermé partage le plan en deux composantes connexes — un intérieur borné et un extérieur non borné. Chaque traversée du polygone bascule de l’une à l’autre.

Pour un domaine à trous, on étend par parité :

Nombre de contraintes traversées depuis l’extérieurStatut
0extérieur (hors du contour englobant)
1intérieur du domaine maillé
2dans un trou
3îlot dans un trou
…alterne

Le flood-fill par parité :

  1. Tout triangle adjacent au super-triangle est étiqueté « extérieur ».
  2. On propage en BFS le long des voisins :
    • Si l’arête entre le triangle courant et son voisin est contrainte ⇒ on inverse la couleur.
    • Sinon ⇒ on conserve.
  3. À la fin, on garde uniquement les triangles « intérieurs ».

Cela fonctionne pour n’importe quel nombre de trous emboîtés, sans tester explicitement la containment.

Raffinement de Ruppert : points Steiner

L’algorithme de Ruppert (1995) raffine une CDT pour satisfaire deux critères de qualité utilisateur :

  • une longueur d’arête maximale \(h_{\max}\) ;

  • un angle minimum \(\alpha_{\min}\), équivalent à un critère sur le rapport circumrayon / arête plus courte :

    \[ \frac{r}{L_{\min}} \le \frac{1}{2 \sin \alpha_{\min}} \]

Encroachment

Une arête contrainte \(AB\) est dite encroachée par un point \(p\) si \(p\) est strictement à l’intérieur du disque diamétral de \(AB\) — c’est-à-dire le disque de centre \(M = (A + B)/2\) et de rayon \(|AB|/2\). Condition :

\[ | p - M |^2 < \frac{| A - B |^2}{4} \]

Boucle de Ruppert

boucle:
    Si une arête contrainte AB est encroachée par un sommet :
        couper AB en son milieu M (M devient un Steiner, AB est remplacé par AM + MB).
        continuer.

    Sinon, chercher un triangle "mauvais" :
        - longueur d'arête > h_max, OU
        - rapport r/L_min > 1/(2 sin α_min) (triangle "skinny")
    Si aucun : terminé.

    Sinon, soit C le centre du cercle circonscrit du mauvais triangle.
    Si C encroache une arête contrainte :
        couper cette arête en son milieu (Steiner) — retour au début de boucle.
    Sinon :
        insérer C par Bowyer-Watson contraint.

L’insertion contrainte (Bowyer-Watson modifié) propage la cavité en BFS depuis le triangle contenant \(C\), sans jamais franchir d’arête contrainte. Cela préserve toutes les contraintes initiales et les nouvelles (milieux d’arêtes).

Contour figé dans pyrucast. L’algorithme de Ruppert ci-dessus coupe une arête de bord encroachée en son milieu (les deux étapes « couper AB en son milieu »). triangulate_surface ne le fait pas : le contour d’entrée doit être conservé à l’identique (mêmes NodeId, mêmes positions). Un point ou un circoncentre qui encroache une arête contrainte est donc simplement abandonné au lieu de la bissecter. Le raffinement ne pose que des Steiner intérieurs ; un bord finement maillé se prépare en amont (mesher.line/arc/circle).

Convergence

La preuve de terminaison de Ruppert (renforcée par Shewchuk en 1996) tient pour

\[ \alpha_{\min} \le 20.7^{\circ} \approx \arcsin\!\frac{1}{2\sqrt{2}} \]

Au-delà de cette borne, certaines configurations très étirées peuvent conduire à des cycles d’insertion (chaque point inséré crée un nouveau triangle « skinny »). pyrucast plafonne le nombre total d’insertions à \( 50 \cdot n_\text{contour} + 1000 \) ; si la limite est atteinte, la fonction renvoie une erreur explicite plutôt que de boucler indéfiniment.

Garanties asymptotiques

Pour des entrées « raisonnables » (arêtes du contour formant des angles entre elles ≥ 60°), le maillage final a :

  • toutes les arêtes \(\le h_{\max}\) ;
  • tous les angles \(\ge \alpha_{\min}\) ;
  • une taille proche du minimum (constante de l’ordre du logarithme du ratio AABB / arête la plus courte de l’entrée).

Références

  • Bowyer, A. Computing Dirichlet tessellations. Computer Journal 24(2), 1981.
  • Watson, D. F. Computing the n-dimensional Delaunay tessellation with application to Voronoi polytopes. Computer Journal 24(2), 1981.
  • Newell, M. E. The utilization of procedure models in digital image synthesis. PhD thesis, Univ. of Utah, 1975.
  • Ruppert, J. A Delaunay refinement algorithm for quality 2-dimensional mesh generation. J. Algorithms 18(3), 1995.
  • Shewchuk, J. R. Delaunay refinement mesh generation. PhD thesis, CMU, 1997.
  • Shewchuk, J. R. Triangle: Engineering a 2D quality mesh generator and Delaunay triangulator. WACG, 1996.

Opérateurs de construction

Les champs matériau prêts pour l’assemblage vivent dans ops::element_field, avec les autres producteurs de champs aux points de Gauss : le module porte le nom du conteneur qu’il produit. L’ancien module ops::build, dont le nom ne désignait aucune famille, a disparu avec le redécoupage.

Champs matériau

Un assemblage a besoin d’un ElementField portant les propriétés matériau (conductivité k, module E, Poisson nu…) aux points de Gauss. Plutôt que de construire ce champ à la main, ces opérateurs le fabriquent en appariant les zones aux sous-modèles qui consomment du matériau, et en ignorant ceux qui n’en ont pas (les contraintes comme Dirichlet).

PythonEffet
material_field(model, [(nom, valeur), …])un ElementField uniforme : une zone par sous-modèle consommateur de matériau, chaque composante demandée mise à la valeur donnée
material_field_per_sub_model(model, [[(nom, valeur), …], …])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 ElementField ordinaire : on peut le faire varier dans l’espace (écriture par zone, par cellule) ou le mettre à l’échelle (arithmétique de champ) — voir Champ aux points de Gauss.

Opérateurs géométriques

Le module ops::geom est réservé aux mesures géométriques : tout ce qui prend un Mesh / SubMesh (et éventuellement un champ de coordonnées) et renvoie un scalaire ou une grandeur géométrique dérivée.

Localisation et projection

Deux primitives d’appariement géométrique, aujourd’hui internes (API Rust, sous les contraintes qui les consomment) :

  • locate_points(host, points, tol) — mapping iso-paramétrique inverse : pour chaque point, la maille hôte qui le contient et ses coordonnées de référence ξ (Newton sur x − Σ Nᵢ(ξ)·Xᵢ, test d’appartenance au domaine de référence), d’où les poids Nᵢ(ξ) et les nœuds de la maille. C’est la brique du baignage (u(p) = Σᵢ Nᵢ·u(hôteᵢ)).
  • project_points(surface, points) — projection au point le plus proche sur un maillage surfacique (facettes de dimension sdim−1 : SEG2/SEG3 en 2D, TRI*/QUA* en 3D). Pour chaque point, la facette la plus proche, ξ clampé au domaine de référence (bords/coins gérés par le clamp), poids Nᵢ(ξ), normale orientée et jeu signé (x − p)·n. C’est la brique du contact.

Nœud le plus proche — une méthode, plus un opérateur

mesh.nearest_node(point) rend le nœud du maillage le plus proche (distance euclidienne) de point. Question purement nodale, complémentaire de locate_points (qui, elle, rend la maille contenant le point) : seuls les nœuds effectivement référencés par une maille sont candidats, et les ex-æquo sont départagés par le plus petit identifiant, donc le résultat est déterministe. Pratique pour cibler un nœud où poser une condition aux limites ou lire un résultat quand on connaît sa position approximative mais pas son id.

Elle n’est plus un opérateur : elle a quitté ops::geom pour devenir une méthode de Mesh, des deux côtés. C’est ce que dit la règle — un seul conteneur, un point pour tout autre argument, et une vue dérivée bon marché : le cas typique de la méthode, pas de la fonction libre. Elle vivait dans ops::geom par voisinage thématique avec locate_points, et cela créait une asymétrie que la convention interdit : fonction libre côté Rust, méthode côté Python.

C’est aussi la requête « un seul nœud » de la famille de sélection par région géométrique — points_in_sphere, points_on_plane, points_in_cylinder, points_on_cone, points_on_torus… — documentée avec les opérateurs de maillage. Ces opérateurs-là rendent toujours un maillage POI1 ; le point le plus proche étant unique, c’est un Node, et il ne rentre donc pas dans la famille.

Sont encore prévus, au fil des besoins : boîtes englobantes (AABB), centroïdes, aires/volumes, métriques de qualité d’élément. Les briques de Jacobien existantes vivent aujourd’hui sur le SubFiniteElementSpace (jacobian, det_jacobian, dn_dx).

Opérateurs sur les champs

Les modules ops::node_field, ops::element_field, ops::coords, ops::measure et ops::field dérivent et transforment les champs — chacun nommé d’après ce qu’il produit : coordonnées, restriction, fusion, et dérivations géométriques vers les points de Gauss. Ces opérateurs croisent des conteneurs (maillage + champ, espace EF

  • champ) — ce sont donc des fonctions libres. L’arithmétique scalaire et par composante, elle, reste sur les types de champ (cf. Champ).

Les autres thèmes d’opérateurs ont leur propre page : construction du matériau (Construction), Assemblage (dont le chargement réparti flux), Comportement, Solveur.

Coordonnées et déplacement

PythonEffet
positions(mesh, components=None)un NodeField portant les coordonnées des nœuds ("X", "Y", "Z"), une zone par sous-maillage. None ⇒ tous les axes présents dans la dimension du Coords.
coords.set(field, components=None)écrit les coordonnées du Coords actif depuis un champ "X"/"Y"/"Z".
displace(field, components=None)ajoute un champ de déplacement aux coordonnées (chaque nœud distinct traité une seule fois).

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

PythonEffet
restrict(field, mesh)restreint un NodeField aux nœuds de mesh (une zone par sous-maillage cible). Le support est le nuage POI1 canonique du sous-maillage (to_poi1, matérialisé une fois et mis en cache) : deux restrictions sur le même mesh partagent le support ⇒ restrict(a,mesh) - restrict(b,mesh) se soustrait directement, et s’aligne avec K·restrict(f,mesh) / solve(K,f). Pour les ops élément (gradient, integral, …), repasser mesh à côté. 0.0 pour les nœuds non couverts ; nœuds hors de mesh abandonnés. Erreur si mesh n’est pas sur le même Coords.
restrict_like(field, target)reprojette field sur le support et les composantes de target, zone par zone (mêmes slots que target) ⇒ le résultat se combine directement avec target par les opérateurs + - * /. Nœuds/composantes de field absents de target abandonnés ; 0.0 si non couverts. Typiquement pour replier un incrément de solve (qui porte aussi les multiplicateurs) dans une solution courante. Erreur si Coords différents.
merge(a, b)union structurelle de deux NodeField, consolidée — c’est l’alias nommé de a | b.
node_field.consolidate(field)fusionne les zones de même support (handle identique) en vérifiant la cohérence des valeurs partagées.
element_field.consolidate(field)fusionne les zones d’une même FiniteElementSpace (union des composantes) — le pendant de `

La consolidation d’un NodeField est exactement la finalisation de l’union | : après déduplication par handle, les zones définies sur le même SubMesh deviennent une seule zone portant l’union de leurs composantes — une composante définie par plusieurs zones doit y avoir la même valeur partout (sinon erreur). Une vérification inter-supports finale impose qu’un nœud partagé par des zones de supports différents s’accorde sur toute composante commune. Le même consolidate accepte un ElementField (opération element_field.consolidate) : les sous-champs d’une même FiniteElementSpace fusionnent en une zone portant l’union de leurs composantes — utile pour réunir des zones matériau bâties par physique sur une fespace partagée (k thermique + E/nu/alpha mécanique) en un champ matériau unique lu par chaque physique.

Bande de valeurs (ge / gt / le / lt)

select et mask partagent la même bande de valeurs, fixée par quatre bornes de comparaison qui reprennent une pour une les opérateurs Python :

ArgumentTestOpérateur
gev ≥ ge>=
gtv > gt>
lev ≤ le<=
ltv < lt<

On donne au plus une borne basse (ge ou gt) et au plus une borne haute (le ou lt), et au moins une borne en tout ; une borne absente laisse ce côté ouvert. Erreur si aucune borne, ou si la borne basse dépasse la haute.

Sélection par valeur

select extrait, zone par zone, la partie du support d’un champ dont les valeurs tombent dans la bande. C’est un filtre par valeur qui renvoie un Mesh — un sous-maillage par zone traitée (les zones restent séparées, rien n’est moyenné ni fusionné).

PythonEffet
select(field, ge=None, gt=None, le=None, lt=None, components=None)sous-ensemble du support du champ respectant la bande, une zone à la fois.
  • Type de champ. Sur un NodeField / SubNodeField, on sélectionne les nœuds : chaque zone donne un sous-maillage POI1 des nœuds retenus. Sur un ElementField / SubElementField, on sélectionne les cellules : chaque zone donne un sous-maillage de son propre type d’élément, et une cellule n’est retenue que si tous ses points de Gauss passent (la bande doit tenir tout le long de la cellule).
  • Composantes. components=None teste toutes les composantes de chaque zone. Une liste components ne teste que ces composantes, et seulement sur les zones qui les portent toutes — une zone à laquelle il manque une composante demandée est ignorée (aucun sous-maillage produit).
  • Combinaison (ET). Quand plusieurs composantes sont testées, elles sont combinées en ET : un nœud / une cellule n’est retenu que si chaque composante testée est dans la bande.
# Nodes whose temperature lies between 20 and 80 °C (inclusive bounds).
chauds = pyrucast.mesh.select(temperature, ge=20.0, le=80.0)

# Cellules dont la contrainte de von Mises dépasse un seuil (borne basse seule).
critiques = pyrucast.mesh.select(sigma, ge=250e6, components=["vm"])

Masque par valeur

mask garde la structure exacte du champ (mêmes zones, même support, mêmes composantes) et se contente de réécrire les valeurs : 1.0 là où la bande tient, 0.0 sinon — composante par composante (le MASQUE de Cast3M). Le résultat est donc du même type que l’entrée et se multiplie terme à terme avec elle. Un NodeField est masqué par nœud, un ElementField par point de Gauss.

PythonEffet
mask(field, ge=None, gt=None, le=None, lt=None, components=None)champ 0/1 de même structure que l’entrée.
  • Pas de ET entre composantes (contrairement à select) : chaque valeur est testée pour elle-même.
  • Composantes. components=None teste toutes les composantes. Une liste components ne teste que celles-ci ; les autres restent à 1.0 (neutre pour le produit), et une zone à laquelle il manque une composante demandée reste tout à 1.0.
# Resets a field's negative values to zero, component by component.
positif = champ * champ.mask(ge=0.0)

# Sugar: comparisons build a mask directly.
positif = champ * (champ >= 0.0)  # the same thing
chauds = temperature > 80.0  # NodeField 0/1

Les opérateurs >=, >, <=, < sur un champ (NodeField, SubNodeField, ElementField, SubElementField) renvoient le masque correspondant contre le scalaire de droite. == / != gardent leur sens Python habituel (identité).

Exemple complet et exécutable : examples/field_mask.py (lancer avec python examples/field_mask.py après maturin develop).

Extraction et renommage de composantes (EXCO)

Deux opérateurs travaillent sur le jeu de composantes d’un champ, sans toucher au support ni aux valeurs — l’équivalent de EXCO de Cast3M. Tous deux acceptent les quatre saveurs (NodeField, SubNodeField, ElementField, SubElementField) et renvoient la même saveur.

PythonEffet
filter_components(field, components)ne garde que les composantes nommées, zone par zone. components est un nom (str) ou une liste de noms — typiquement le résultat de model.primal_vars().
rename_component(field, old, new)renomme la composante old en new (métadonnée seule, aucune valeur déplacée).

filter_components traite chaque zone indépendamment :

  • une zone ne portant aucune des composantes demandées est abandonnée ;
  • une zone ne portant que des composantes demandées (rien à retirer) voit son sous-champ partagé tel quel (handle copié, pas de duplication) ;
  • une zone mixte est reconstruite sur le même support avec les seules composantes demandées, dans son propre ordre.

components peut être un sur-ensemble des composantes du champ (les noms absents sont ignorés) : passer model.primal_vars() à un résultat de solve pour en retirer les inconnues duales (multiplicateurs de Lagrange) est l’usage visé. Erreur si aucune zone ne porte l’une des composantes demandées.

rename_component laisse inchangée (handle partagé) toute zone ne portant pas old. Erreur si aucune zone ne porte old, ou si une zone concernée a déjà une composante nommée new.

# Removes the Lagrange multipliers from a solve result.
u = solution.filter_components(model.primal_vars())

# Renames a component before exporting.
export = u.rename_component("u_x", "DX")

Sucre d’indexation (façon pandas/numpy) : sur les quatre saveurs (NodeField, SubNodeField, ElementField, SubElementField), une clé chaîne ou liste de chaînes appelle filter_components et renvoie la même saveur. Les autres clés gardent leur sens : int/slice → accès aux zones sur un agrégat ; le tuple d’accès à une valeur sur un sous-champ (sub[node, "UX"], sub[cell, gauss, "E"]) est inchangé.

ux = champ["u_x"]  # == filter_components(champ, "u_x")
depl = champ[["u_x", "u_y"]]  # == filter_components(champ, ["u_x", "u_y"])
zone = champ[0]  # inchangé : la zone (SubNodeField)
val = champ[0][node, "u_x"]  # unchanged: the value at the node

L’accesseur champ.components() (présent sur les quatre saveurs) donne la liste des composantes, d’où l’idiome « reprojeter u1 sur les composantes de u2 » :

u = u1[u2.components()]  # u1 cut down to u2's set of components

Côté Rust, filter_components / select_components acceptent indifféremment un &str, un tableau ["u_x", "u_y"] ou un Vec<String> (trait IntoComponentNames) — donc field.filter_components(model.primal_vars()) passe directement.

Dérivation géométrique (vers les points de Gauss)

Ces opérateurs ne dépendent que de l’espace EF et du champ — aucune physique. Ils produisent l’ElementField que le comportement (integrate_behavior) consomme ensuite. 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 :

CoordsDDL luscomposantes produites
1-Dw, thetakappa, gamma
2-Du_x, u_y, r_zeps, kappa, gamma
3-Dsixeps, kappa_y, kappa_z, torsion, gamma_y, gamma_z

C’est le produit B · u, avec le B de l’élément (models::beam::b_into) — celui-là même dont la rigidité intègre Bᵀ D B et dont les forces internes intègrent le transposé. Les déformations sont évaluées à chaque point de Gauss : la courbure varie le long d’une travée non chargée (M' = V), seul le cisaillement est constant (V' = 0).

Le matériau est exigé, et c’est la signature honnête : Φ = 12EI/(G·A_s·L²) décide de la distribution de courbure. Un matériau sans constantes de cisaillement — celui d’une poutre de Bernoulli, qui ne demande ni G ni A_s — signifie Φ = 0, si bien que le même opérateur sert les deux théories sans qu’on ait à lui dire laquelle. Le résultat se donne au comportement pour obtenir les efforts de section.

shell_deformation(field, fespace, 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.

formulationcomposantes produites
thickeps_xx, eps_yy, eps_xy, kappa_xx, kappa_yy, kappa_xy, drill, gamma_xz, gamma_yz
kirchhoffles mêmes, sans les deux déformations de cisaillement

Toutes dans le repère local de la facette. drill est le résidu de vrillage θ_z − ω_z, dont l’effort conjugué est un moment que la rigidité intègre comme les autres.

Le cisaillement transverse est constant par élément, échantillonné au point réduit — le point même où la rigidité l’intègre. Ce n’est pas une simplification : c’est l’intégration réduite qui empêche une coque mince de bloquer, lue de l’autre côté. L’échantillonner ailleurs rapporterait une déformation que l’élément ne porte pas.

Maths élément par élément

Onze fonctions appliquent une fonction scalaire à chaque valeur d’un champ et renvoient un nouveau champ du même type (style numpy). Elles acceptent indifféremment les quatre saveurs de champ — NodeField, SubNodeField, ElementField, SubElementField — et dispatchent par type.

PythonEffet
abs(field)valeur absolue
sqrt(field)racine carrée (nan pour les négatifs)
exp(field)exponentielle eˣ
log(field)logarithme népérien (-inf/nan pour ≤ 0)
log10(field)logarithme base 10
cos(field) / sin(field) / tan(field)trigonométrie (radians)
sinh(field) / cosh(field) / tanh(field)trigonométrie hyperbolique

Les résultats sont non bornés, comme en numpy : aucune protection sur le domaine (log de ≤ 0 donne -inf/nan, sqrt d’un négatif donne nan). Ces fonctions se combinent à l’arithmétique scalaire des champs (f + s, f * s, cf. Champ) pour bâtir des expressions par composante.

# Atténuation exponentielle d'un champ de température.
attenue = pyrucast.field.exp(temperature * -0.1)

# Magnitude of a field (combined with scalar field arithmetic).
amplitude = pyrucast.field.abs(signal)

Réduction

Deux produits scalaires, à ne pas confondre — ils diffèrent par ce qui est réduit et donc par le type du résultat :

PythonCast3MRéduitRésultat
xty(x, y)XTYtout (nœuds/points × composantes)un float
psca(x, y)PSCAles composantes seules, nœud par nœudun champ à une composante "psca"

Les deux exigent la même saveur d’opérandes (NodeField, SubNodeField, ElementField, SubElementField), alignent les composantes par nom (l’ordre peut différer) et 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) → un float ;
  • psca (pscal / pscal_field) → un champ (composante "psca"), une zone par support partagé.

xty(x, y) → float

Produit scalaire global des deux champs entiers :

\[ x \cdot y = \sum_i \sum_c x_{i,c}\, y_{i,c}, \]

la somme parcourant toutes les valeurs. Le résultat est un unique float : le produit scalaire qui sert au calcul d’énergie (F·u), aux normes de résidu, etc. L’addition flottante n’étant pas associative, le total dépend du nombre de threads jusqu’au dernier ULP — comme le solveur, ce n’est pas reproductible bit à bit.

# External strain energy: work of the nodal forces in the displacement field
# (same components, same mesh).
energie = pyrucast.measure.xty(forces, deplacements)

psca(x, y) → champ (même saveur que les entrées)

Produit scalaire nœud par nœud (ou point par point) — réduction sur les composantes seules, le support est conservé :

\[ p_i = \sum_c x_{i,c}\, y_{i,c}. \]

Le résultat est un nouveau champ de la même saveur que les entrées, portant une seule composante "psca" : la valeur du produit scalaire à chaque nœud. Chaque sortie est écrite une fois (par nœud) ⇒ indépendant du nombre de threads.

# Squared norm of a vector field, node by node.
norme2 = pyrucast.field.psca(vitesse, vitesse)  # one-component field, "psca"

integral(field, component, fespace=None) → float

Intègre un champ sur son support par la quadrature éléments finis, ∫_Ω f dΩ — le total d’une composante (p.ex. la résultante d’une densité de force distribuée) :

\[ \int_\Omega f \, d\Omega \;=\; \sum_{\text{cell}} \sum_g f(\text{cell}, g)\, |J|_g\, w_g . \]

  • sur un NodeField : les valeurs nodales sont relevées aux points de Gauss par les fonctions de forme, ∫ Σ_i f_i N_i dΩ — fespace est requis ;
  • sur un ElementField : les valeurs (déjà aux points de Gauss) sont intégrées directement — fespace est ignoré.

Comme xty, la somme flottante dépend du nombre de threads jusqu’au dernier ULP. En interne, la réduction parallèle sur les cellules passe par le driver kernel::reduce_cells.

# Resultant of a surface force density f_y on a plate (through N_i).
r_y = pyrucast.measure.integral(densite, "f_y", fespace=fes)
# Measure of the domain: ∫ 1 dΩ.
aire = pyrucast.measure.integral(champ_unite, "u", fespace=fes)

Somme et xtx

Pour une résultante de forces déjà nodales (sortie de internal_forces, réactions…), la résultante est une simple somme par nœud — exposée comme méthode, à côté de min / max :

RéductionRéduitRésultat
field.min(comp) / field.max(comp)une composanteun float (exact)
field.min() / field.max()toutes les composantesun float (exact)
field.sum(comp)une composante (Σ nœuds/points)un float
xtx(field)toutes les valeurs au carré (Σ v², XTX)un float
xtx(field, components=[…])seules ces composantes au carréun float

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.

  1. l’entrée de déformation ε(B) est construite séparément et géométriquement par gradient (∇T…), deformation (ε), beam_deformation (κ, γ) ou shell_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 ;
  2. prev est l’état convergé de A — la sortie du pas précédent : la contrainte σ(A), les variables internes VAR(A) et la déformation ε(A). Il vaut None au premier pas, où A est la configuration de référence (σ(A)=0, ε(A)=0) ;
  3. dt est l’incrément de temps, None pour une loi indépendante du temps (une loi visqueuse future erreurera s’il vaut None) ;
  4. integrate_behavior prend ces entrées plus le matériau par zone et applique la loi de chaque physique point par point ;
  5. il renvoie l’état matériau de B : le flux / la contrainte dual(e) plus les variables internes mises à jour VAR1 — le champ à réinjecter comme prev au pas suivant.

Les sous-modèles de contrainte (Dirichlet…) sont ignorés : un sous-modèle participe ssi il déclare un espace EF de comportement. Les zones de déformation, de matériau et d’état précédent sont appariées par sous-espace EF.

Pourquoi le montage incrémental ? Fournir l’état de A séparément de la déformation de B (plutôt que fusionnés dans un même champ) rend le fil d’état robuste — un ElementField = une zone par support, sans ambiguïté — et ouvre les 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 :

  1. obtient la factorisation de A (cache de la matrice, cf. ci-dessous) ;
  2. lit le NodeField de chargement à chacune des lignes de la matrice (les entrées absentes valent 0.0 par défaut) ;
  3. effectue la descente/remontée pour ce second membre ;
  4. emballe la solution dans un NodeField indexé sur les colonnes de la matrice (les primales : déplacements, températures, multiplicateurs…).

La solution a une zone par support colonne des blocs de la matrice, chaque zone vivant sur le handle POI1 du bloc lui-même (aucun support n’est reconstruit — ces supports sont créés une fois à la construction des sous-modèles et réutilisés d’assemblage en assemblage). Elle est donc same_support avec tout champ posé sur ces supports, et deux résolutions successives partagent les mêmes supports : leur arithmétique (a - b, …) s’aligne zone à zone. Sur un modèle contraint (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 (True par défaut ; False factorise à 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) ; le K physique 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 options method / cache ;
  • un modèle sans contrainte retombe sur un solve simple.

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 :

  1. partir d’un statut d’essai (toutes actives, ou le statut convergé précédent quand le cache est chaud — un warm start) ;
  2. résoudre le système point-selle avec, pour chaque relation inactive, sa ligne de contrainte remplacée par λᵣ = 0 (la matrice garde sa taille) ;
  3. vérifier les signes : une relation active dont le λ tire (signe inadmissible pour son sens) est relâchée ; une relation inactive dont le jeu pénètre est activée ;
  4. aucun changement de statut ⇒ convergence (la boucle finie classique du statut) ; sinon on recommence.

La convention de signe vient du système point-selle assemblé K·u + Cᵀ·λ = f, C·u = g : contre le multiplicateur KKT μ ≥ 0 d’une contrainte ≥ on a λ = −μ. Donc, dans le champ solution : ≥ active a λ ≤ 0 (relâchée si λ > tol) ; ≤ active a λ ≥ 0 (relâchée si λ < −tol).

  • les relations d’égalité du modèle sont imposées inconditionnellement, comme par solve ; un modèle sans inégalité retombe sur un solve simple ;
  • la structure des contraintes est lue via le seam méthode-neutre Constraint::relations() (partagé avec les voies Lagrange et élimination) ;
  • options : method / cache (comme solve), 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 × k G = I + Vᵀ X est l’opérateur de Delassus restreint aux relations actives — dense, factorisé par une LU dense (nalgebra) à chaque itération (coût k³/3, négligeable jusqu’à quelques milliers de contacts) ; ses entrées se lisent des colonnes cachées : Gᵢⱼ = δᵢⱼ + Cᵢ·xⱼ − xⱼ[λcolᵢ] ;
  • une itération de statut ne coûte donc plus aucune factorisation creuse — seulement des descentes/remontées sur A (cachée) et une LU dense k × k. Un re-solve à chargement identique ou proche est quasi gratuit.

Repli automatique sur socle singulier. Le socle A doit être inversible, c.-à-d. la structure doit tenir sans aucun contact (bloquée par ailleurs). Un corps simplement posé sur un appui n’a pas ce luxe : A est singulière (mode rigide) alors que la méthode du statut converge très bien. Comme la LU creuse peut factoriser une matrice singulière en valeurs finies fausses sans erreur, la non-singularité du socle est confirmée par un aller-retour A⁻¹·(A·1) ≈ 1 ; s’il échoue, la voie "schur" retombe automatiquement sur "refactorize" (marqué une fois pour toutes sur la matrice). Aucune régression possible : le résultat est le même, seul le coût change.

K = pyrucast.matrix.stiffness(model, materials)  # model with sense=">="
solution = pyrucast.solver.solve_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 :

DDLassemblagesolve (Lagrange + LU)solve_eliminate + method="cholesky"
31 k28 Mo1,01 Go, 10,8 s0,28 Go, 1,8 s
135 k118 Mo10,6 Go, 330 s2,22 Go, 42 s
363 k348 Mo—7,20 Go, 217 s

Premier levier, donc : quand le système éliminé est symétrique défini positif (thermique, élasticité), solve_eliminate(…, method="cholesky") divise le pic par cinq et le temps par huit.

Un cube est le pire cas du remplissage. Une pièce mince ou élancée s’en tient bien en dessous, à nombre de DDL égal.

Déborder sur disque sans être root

Une roue compilée avec la feature spill (Linux ; c’est le cas de la roue Python) sait placer ses grosses allocations dans des fichiers mappés en mémoire plutôt qu’en mémoire anonyme. Le noyau peut écrire ces pages sur disque et les évincer sous pression, puis les recharger au besoin. C’est un swap, sans swap configuré et sans droit root. Les facteurs de faer en profitent sans le savoir.

VariableRôle
PYRUCAST_SPILL_DIRRépertoire des fichiers de débordement. Absente, le débordement est inactif.
PYRUCAST_SPILL_MINTaille, en octets, à partir de laquelle une allocation déborde. Défaut : 64 Mio.
PYRUCAST_SPILL_LOGPrésente, chaque bloc mappé et démappé s’écrit sur la sortie d’erreur.

Les variables sont lues une seule fois, à la première allocation du processus. Il faut donc les poser avant de le lancer, par exemple PYRUCAST_SPILL_DIR=/scratch/moi python calcul.py, et non depuis le script. Un répertoire illisible arrête le processus avec un message. Les fichiers sont anonymes (O_TMPFILE) : rien à nettoyer, même après un plantage.

Le répertoire doit être sur un disque local. /tmp est souvent un tmpfs, c’est-à-dire de la RAM, et y déborder ne sert à rien. Un montage réseau (NFS) rendrait chaque éviction très lente. Il faut aussi la place des facteurs : l’espace est réservé à l’allocation, donc un disque plein fait échouer l’allocation au lieu de planter plus loin.

Même cube à 363k DDL, Cholesky, RAM suffisante, débordement sur ext4, les cinq configurations dans une même série :

Configurationblocs débordéspic anonymepic totaltemps
sans la feature spill—6,84 Go6,84 Go163 s
feature, sans PYRUCAST_SPILL_DIR—6,84 Go6,85 Go159 s
seuil 8 Gio06,87 Go6,88 Go160 s
seuil 1 Gio21,13 Go6,85 Go441 s
seuil 64 Mio250,26 Go6,84 Go475 s

La solution est identique au bit près dans les cinq cas.

Le test ne coûte rien de mesurable : sans répertoire de débordement, ou avec un seuil au-dessus du plus gros bloc, on retrouve le temps du binaire compilé sans la feature — l’écart entre les trois premières lignes est du bruit.

Le pic total ne bouge pas, et c’est voulu : tant que la RAM est libre, le noyau garde en cache les pages des fichiers. Ce qui change, c’est qu’elles sont évinçables. La colonne qui décide si un calcul passe ou se fait tuer est le pic anonyme, qui tombe ici de 6,84 Go à 1,13 Go.

Le prix est payé même sans pression mémoire : une page écrite d’un fichier mappé part sur disque au bout d’une trentaine de secondes, pression ou non, et ce délai n’est réglable que par root. D’où un débordement à la demande, par exécution. Ici, ×2,7 à ×2,9 sur un RAID à ~50 Mo/s utiles ; un NVMe en demanderait beaucoup moins.

Choisir le seuil

Le seuil ne connaît pas les types : tout tampon assez gros déborde, quel qu’il soit. Le monter haut ne laisse partir que les tableaux qui comptent vraiment, et garde en RAM les données petites et souvent relues. Sur le cube ci-dessus, avec un seuil de 1 Gio, deux blocs seulement sont partis sur disque — 5,22 Go, qui sont les valeurs du facteur de Cholesky, et 1,08 Go de tampon de factorisation ; la CSR (146 Mo) est restée en mémoire. Le seuil bas, lui, fait déborder vingt-cinq blocs pour 0,9 Go d’anonyme gagnés de plus.

Un seuil de l’ordre du gigaoctet est donc le réglage d’un gros calcul ; un seuil bas ne se justifie que si la RAM manque à ce point. L’écart de temps entre les deux, environ 8 %, est du même ordre que la variabilité d’une exécution à l’autre.

spill_stats() en Python, spill::stats() en Rust, rendent le seuil, le nombre de blocs débordés, le plus gros, ce qui est mappé à l’instant et le maximum d’un coup — en octets, le seuil valant None quand rien ne déborde. PYRUCAST_SPILL_LOG donne la même chose bloc par bloc, au fil de l’eau, dans l’unité qui se lit le mieux.

stats = pyrucast.spill_stats()
if stats["threshold"] is None:
    pass  # PYRUCAST_SPILL_DIR absente : rien ne déborde, tout est en RAM
else:
    print(f"{stats['count']} bloc(s), le plus gros {stats['largest'] / 2**30:.1f} Gio")
    print(f"au plus {stats['peak'] / 2**30:.1f} Gio mappés d'un coup")

Le journal, lui, ressemble à ceci :

pyrucast spill: +5.2 GB, 5.2 GB mapped
pyrucast spill: +1.0 GB, 6.2 GB mapped
pyrucast spill: -1.0 GB, 5.2 GB mapped

Déterminisme

Contrairement au reste des opérateurs (bit-à-bit identiques quel que soit le nombre de threads), le solveur n’est pas bit-à-bit identique à l’ancien LU dense : pivotage et ordering diffèrent. Les résultats restent dans les tolérances numériques usuelles.

Exemples complets

La résolution de bout en bout (assemblage + contraintes + lecture de la solution et des multiplicateurs de réaction) est déroulée sur des cas à solution analytique :

Sauvegarde et relecture

Écrire des objets dans un fichier, et les relire en gardant ce qu’ils partageaient. Un dictionnaire à l’aller, un dictionnaire au retour.

pyrucast.save(
    "etude.pyr",
    {
        "maillage fin": mesh,
        "T (°C)": temperature,
        "materiaux": mat,
        "time step": 0.05,
        "instants": [0.0, 0.1, 0.2],
    },
)

objets = pyrucast.load("etude.pyr")
mesh2 = objets["maillage fin"]
t2 = objets["T (°C)"]

Les clefs sont libres : un espace, un accent, une unité — tout ce qu’une chaîne Python peut porter. C’est la raison du dictionnaire explicite plutôt que des mots-clefs.

La garantie : le partage survit

C’est la propriété pour laquelle ce format existe. Deux champs bâtis sur un même support sont relus sur un support, pas sur deux copies aux mêmes nœuds :

t = pyrucast.NodeField(support, ["T"])
f = pyrucast.NodeField(support, ["f"])
pyrucast.save("etude.pyr", {"T": t, "f": f})

o = pyrucast.load("etude.pyr")
assert len(o["T"] | o["f"]) == 1  # a single zone: the support is one object

L’union fusionne les zones qui partagent un support et laisse côte à côte celles qui n’en partagent pas — c’est l’observable la plus directe du partage. Sans cette garantie, un champ et son maillage relus ne combineraient plus : ils porteraient les mêmes nœuds sans être le même support, et toute l’arithmétique de champs tomberait à côté.

Le mécanisme tient en une phrase : les objets sont écrits sous des identifiants locaux au fichier, et un objet référencé deux fois n’est écrit qu’une fois. Une adresse mémoire n’aurait aucun sens dans un autre processus ; un numéro de fichier, si.

On donne les racines, pas la liste des dépendances

save prend ce qui vous intéresse. Ce dont ces objets ont besoin suit tout seul :

pyrucast.save("m.pyr", {"maillage": mesh})  # also writes the Coords and the submeshes

Il n’y a rien à énumérer, et rien à oublier.

Relire ajoute, ne remplace pas

load fabrique des objets neufs et vous rend le dictionnaire. Ce qui vivait déjà dans votre session n’est pas touché : on peut relire deux fois le même fichier et obtenir deux graphes indépendants, ou relire un maillage à côté de celui qu’on manipule.

Ce que le fichier ne porte pas

La règle est simple : ce qui se recalcule ne s’écrit pas.

Sortent donc du fichier tous les caches et toutes les mémoïsations — la matrice assemblée, la factorisation du solveur, le coloriage des mailles, les tables d’index paresseuses, la copie qu’un champ garde de la connectivité de son support. Tout cela se rebâtit à la première demande, exactement comme sur un graphe construit à la main :

o = pyrucast.load("etude.pyr")
k = pyrucast.matrix.stiffness(o["modele"], o["materiaux"])  # réassemble
u = pyrucast.solver.solve(k, o["chargement"])  # refactorise

Ce n’est pas une économie de place accessoire : la copie qu’un champ nodal garde de sa connectivité pèse autant que le maillage lui-même, et un fichier qui la porterait la porterait une fois par champ.

Les compteurs de références

Ils ne sont pas écrits non plus, et c’est délibéré : un fichier contient certains des objets qui référencent un nœud, pas tous. Un compteur sauvé décrirait un monde qui n’existe plus.

À la relecture, tout est recompté depuis zéro. Les objets se comptent seuls — chaque référence rendue par load compte pour une. Les nœuds sont réincrémentés par les sous-maillages relus.

Conséquence à connaître : un nœud relu n’est protégé que par les objets présents dans le fichier. Un Node que vous teniez dans une variable Python n’est pas archivé — c’est un atome de votre script, pas un objet du graphe. Sauver une Coords seule et la relire donne donc des nœuds à compteur nul, qu’un gc() collectera :

c2 = pyrucast.load("coords_seules.pyr")["c"]
c2.gc()  # collects everything: nothing in the file held those nodes

C’est exactement l’état où l’on serait après avoir reconstruit les mêmes objets à la main sans en garder de Node. Pour qu’un nœud survive, sauvez ce qui l’utilise.

Les valeurs simples

À côté des objets, le fichier accepte un bool, un int, un float, une str, et les listes homogènes de ces quatre types. De quoi ranger le pas de temps, le nom du cas de charge ou la liste des instants avec les champs auxquels ils se rapportent.

Trois refus, nommés plutôt que silencieux : une liste imbriquée ou un dictionnaire (hors périmètre), une liste hétérogène, et un entier hors des 64 bits du format — les entiers Python sont non bornés, le fichier ne l’est pas.

Le fichier

b"PYRUCAST"                       signature, 8 octets
version de format (u32)           toute autre valeur est refusée, jamais convertie
version du crate                  informative : elle sert au diagnostic, jamais au test
enregistrements                   (identifiant, type, octets), en ordre de dépendance
racines                           les clefs que vous avez données

Sauver deux fois les mêmes objets produit le même fichier, octet pour octet : les clefs sont triées, donc les identifiants sont distribués dans un ordre déterministe. Un fichier d’archive se compare, se met sous gestion de version, se hache.

Le format binaire est identique sous Linux et Windows : entiers petit-boutistes normalisés, usize sur 64 bits, f64 IEEE-754, aucun chemin ni séparateur dépendant du système dans les données.

Avant la version 1.0.0, le format peut changer sans préavis. Une version inconnue est refusée avec un message qui nomme les deux numéros — jamais décodée à moitié.

Ce n’est pas un format d’échange

Un fichier .pyr est le format de session de pyrucast : il sert à reprendre un calcul, pas à le publier. Pour donner des résultats à un autre outil, export_vtk existe pour ça, et ParaView le lit nativement.

La distinction n’est pas de la pudeur. Un format d’échange comme HDF5 apporte l’interopérabilité, la lecture partielle et la compression — mais aucune notion d’identité d’objet ni de référence partagée. La garantie du haut de cette page devrait y être reconstruite à l’identique, par-dessus. À l’inverse, un format de session n’a pas à être lisible par des tiers, et gagne à pouvoir casser tant que la bibliothèque n’est pas figée. Confondre les deux les abîme tous les deux.

Côté Rust

#[test]
fn sauver_et_relire_un_graphe_d_objets() -> Result<()> {
    // `tempfile` is not a dependency of the project: a unique name in the system's
    // temporary directory is enough.
    let chemin = std::env::temp_dir().join(format!("pyrucast_doc_{}.pyr", std::process::id()));
    let chemin = chemin.to_str().unwrap().to_string();
    let chemin = chemin.as_str();

    let coords = Handle::new(Coords::new(2)?);
    let a = Node::create_in(coords.clone(), &[0.0, 0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0, 0.0])?;
    let mut mesh = Mesh::from_submesh(SubMesh::new(coords, ElementType::SEG2));
    mesh.add_cell(&[a.id(), b.id()])?;
    let temperature = NodeField::new(&mesh::to_poi1(&mesh)?, vec!["T".into()])?;

    archive::save(
        chemin,
        &[
            ("maillage fin", &mesh as &dyn archive::ArchiveRoot),
            ("T (°C)", &temperature),
            ("pas de temps", &0.05_f64),
        ],
    )?;

    let mut objets = archive::load(chemin)?;
    let mesh2 = objets.mesh("maillage fin")?; // erreur nommant clef, type attendu, type trouvé
    let dt = objets.float("pas de temps")?;

    assert_eq!(mesh2.cell_count(), 1);
    assert_eq!(dt, 0.05);
    Ok(())
}

À l’écriture les types sont connus du compilateur, une tranche de paires suffit. À la relecture ils ne le sont pas : load rend une table nommée, dont on tire chaque objet avec son type attendu.

Le détail du mécanisme — la découverte des dépendances par la sérialisation elle-même, la détection de cycle, le crochet d’après-relecture — est dans Modèle mémoire et dans la documentation du module archive.

Visualisation

La visualisation des maillages est optionnelle : elle est gardée par des features Cargo et n’est donc compilée que sur demande.

Feature CargoApportDépendances ajoutées
(aucune)rien — bibliothèque de calcul pure—
vizexport PNG + SVG (rendu CPU)plotters
viz-interactive+ fenêtre interactive (souris)plotters, winit, softbuffer

viz-interactive implique viz. Pour un environnement sans serveur d’affichage (CI headless, conteneur), on s’arrête à viz : tout l’export image fonctionne.

Installé depuis PyPI ? Les wheels publiées compilent viz et viz-interactive ; la sdist, sur laquelle pip retombe quand aucune wheel ne correspond à la plateforme, ne compile que le cœur de calcul — mesh.plot() n’y existe pas. pyrucast.__features__ dit ce que porte l’installation en cours, et « Ce que porte chaque distribution » détaille les deux cas.

# Bibliothèque de calcul pure (par défaut).
cargo build

# Avec export PNG/SVG.
cargo build --features viz

# Avec fenêtre interactive.
cargo build --features viz-interactive

# Côté Python (pour les tests pytest) : les features passées en ligne de
# commande *remplacent* celles du pyproject, il faut donc redonner
# `extension-module` en plus de `viz`.
maturin develop --features extension-module,viz

Modèle de point de vue

La caméra est décrite par une structure View, située sur une sphère orientée autour d’un point cible :

  • yaw : azimut en degrés (rotation autour de l’axe Z monde) ;
  • pitch : élévation en degrés (au-dessus du plan XY monde) ;
  • scale : 1.0 = la bounding-box remplit l’image ; >1 zoom, <1 dézoom ;
  • target : point regardé. None ⇒ le centre de la bounding-box de l’objet visualisé ;
  • revolve : sur une géométrie axisymétrique uniquement, balaie la section méridienne pour tracer le corps de révolution (voir plus bas). None (défaut) ⇒ la section plane.

Préréglages disponibles :

#[test]
fn les_vues_predefinies() {
    let _ = View::front(); // yaw=0, pitch=0      : caméra en +X
    let _ = View::side(); // yaw=90, pitch=0     : caméra en +Y
    let _ = View::top(); // yaw=0, pitch=90     : vue du dessus
    let _ = View::iso(); // yaw=45, pitch≈35.26 : isométrique
    let _ = View::default(); // = iso()
}

Convention : yaw = pitch = 0 place la caméra en +X, regard vers l’origine, axe Z vers le haut. Le repère écran qui en résulte est (Y, Z).

Une seule fonction plot

Toute la sortie passe par la même méthode plot(view, save) exposée sur SubMesh et Mesh :

saveEffetCompilation nécessaire
Noneouvre une fenêtre interactive (souris : rotation au glisser, molette : zoom)viz-interactive
Some(path) avec extension .pngécrit un PNGviz
Some(path) avec extension .svgécrit un SVG vectorielviz
Some(path) avec extension .svgzécrit le même SVG, gzippéviz

Tout autre extension est rejetée avec une erreur explicite. Le format vectoriel est ce qui rend ce socle particulièrement utile pour les figures de rapport : on conserve un trait propre quel que soit le zoom.

.svgz : quand une étude sort des figures par centaines

.svgz n’est pas un autre rendu, c’est le .svg compressé : le dézipper rend le fichier octet pour octet. Il pèse environ le dixième, parce qu’un maillage produit un balisage très répétitif, et se lit nativement dans un navigateur ou dans Inkscape.

Il est fait pour s’accumuler sur un disque, pas pour être publié. Sur le web le gain est nul : les serveurs compressent déjà le .svg à la volée, si bien qu’un .svgz ne change pas un octet transféré — et il rendrait binaires des fichiers que git suit très bien en texte. C’est pourquoi les figures de ce livre restent en .svg.

mesh.plot(save="piece.svg")  # to version, to publish
mesh.plot(save="piece.svgz")  # to stack by the hundred

Le .svg lui-même est déjà allégé à l’écriture : le générateur de SVG répète le style complet sur chaque balise, et pyrucast retire ce que le format sait hériter — un dessin identique au pixel près, pour environ la moitié des octets.

Dans la fenêtre interactive uniquement, le point de vue courant view=(yaw, pitch, scale) s’affiche en permanence en haut à droite — le même ordre que le tuple accepté par view=, pour recopier tel quel l’angle atteint à la souris/molette dans un appel plot() ultérieur.

Exemple Rust :

#[test]
fn exporter_un_sous_maillage_en_svg() -> Result<()> {
    let (dossier, coords, _) = scene()?;
    let a = Node::create_in(coords.clone(), &[0.0, 0.0, 0.0])?;
    let b = Node::create_in(coords.clone(), &[1.0, 0.0, 0.0])?;
    let c = Node::create_in(coords.clone(), &[0.0, 1.0, 0.0])?;
    let mut sm = SubMesh::new(coords, ElementType::TRI3);
    sm.add_cell(&[a.id(), b.id(), c.id()])?;

    // Export vectoriel.
    sm.plot(View::iso(), Some(&dossier.join("triangle.svg")))?;
    // Fenêtre interactive (feature `viz-interactive`).
    // sm.plot(View::default(), None).unwrap();
    Ok(())
}

Côté Python, l’API miroir prend des tuples :

import pyrucast

coords = pyrucast.Coords(3)
a = coords.add_node([0.0, 0.0, 0.0])
b = coords.add_node([1.0, 0.0, 0.0])
c = coords.add_node([0.0, 1.0, 0.0])

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

# (yaw, pitch, scale) ; save=None ouvre la fenêtre interactive.
mesh.plot(view=(45.0, 35.264, 1.0), save="triangle.svg")

Nom de figure / de fenêtre (title)

Toutes les méthodes plot(...) — Mesh, SubMesh, NodeField, ElementField — acceptent un argument nommé optionnel title, qui sert de nom de figure :

  • en export fichier (PNG/SVG), il est gravé centré en bas de l’image, dans une bande réservée sous le tracé ;
  • en fenêtre interactive (save=None), il devient le titre de la fenêtre (barre de titre de l’OS).

title=None (défaut) : aucune légende en bas et titre de fenêtre par défaut (pyrucast). Une chaîne vide vaut None.

mesh.plot(
    save="piece.svg", title="cantilever beam"
)  # caption centred at the SVG's bottom
mesh.plot(save="t.svg", field=t_field, title="temperature")  # combines with field
# mesh.plot(title="ma pièce")  # nomme la fenêtre interactive (bloquant)

Pour les courbes d’Evolution / SubEvolution, le title existant 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 par mesh.plot(field=...) avec le maillage d’origine.

Échelles de couleur (cmap)

Cinq colormaps sont disponibles, sélectionnées par leur nom (insensible à la casse). Un nom inconnu lève une erreur listant les noms acceptés.

cmapDégradéUsage
"viridis" (défaut)violet → bleu → vert → jauneusage général ; perceptuellement uniforme, lisible en niveaux de gris et pour les daltoniens
"coolwarm"bleu → blanc → rougedonnées signées centrées sur 0 (le blanc marque le milieu de l’échelle)
"hot"noir → rouge → jaune → blancrendu thermique
"gray"noir → blancimpression N&B, superposition
"jet"bleu → vert → rougeancien défaut, conservé

Bornes de l’échelle (vmin / vmax)

Par défaut l’échelle couvre le min/max des valeurs par cellule. On peut fixer l’une ou l’autre borne (ou les deux) — celle laissée à None continue de suivre les données. Utile pour comparer plusieurs figures sur une échelle commune, ou pour centrer une colormap divergente (vmin = -vmax avec "coolwarm").

Côté Rust, l’argument scale (ColorScale) regroupe colormap et bornes :

#[test]
fn tracer_un_champ_avec_son_echelle() -> Result<()> {
    let (dossier, _, mesh) = scene()?;
    let poi1_h = mesh::to_poi1(&mesh)?.get(0)?;

    // A displacement field with 2 components "UX" / "UY" on a POI1. `FieldArg`
    // takes the **aggregate**: a lone zone is lifted by `NodeField::from_sub`.
    let sub = SubNodeField::from_poi1(&poi1_h, vec!["UX".into(), "UY".into()])?;
    // ... remplissage ...
    let u = NodeField::from_sub(sub);

    // Échelle auto, viridis, première composante, rendu interpolé niveau 4.
    mesh.plot_with_field(
        View::default(),
        Some(&dossier.join("ux.svg")),
        FieldArg::Node(&u),
        None,
        ColorScale::default(),
        4,
        None, // titre
    )?;

    // Component "UY", coolwarm colormap, bounds fixed at [-1, 1], flat.
    let scale = ColorScale {
        cmap: Colormap::CoolWarm,
        vmin: Some(-1.0),
        vmax: Some(1.0),
    };
    mesh.plot_with_field(
        View::default(),
        Some(&dossier.join("uy.svg")),
        FieldArg::Node(&u),
        Some("UY"),
        scale,
        0,
        None, // titre
    )?;
    Ok(())
}

Côté Python, cmap, vmin et vmax sont des arguments nommés de plot :

# Default component, viridis, automatic scale.
mesh.plot(save="t.svg", field=t_field)

# Composante "UY", colormap "coolwarm", bornes fixées.
mesh.plot(
    save="uy.svg",
    field=u_field,
    component="UY",
    cmap="coolwarm",
    vmin=-1.0,
    vmax=1.0,
)

# Ceiling only set: the floor follows the data's minimum.
mesh.plot(save="t.svg", field=t_field, vmax=100.0)

# Field at the Gauss points: strictly the same call.
mesh.plot(save="flux.svg", field=flux_field)

Bouton de sélection dans la fenêtre interactive

En mode interactif (viz-interactive), un bouton cliquable apparaît au sommet de la fenêtre, affichant la composante actuelle et son intervalle. Deux manières équivalentes d’en changer :

  • Clic sur le bouton — cycle dans l’ordre des composantes du champ ;
  • Touche Tab — même effet, sans toucher à la souris.

La caméra (rotation à la souris, molette, axes affichés via A) continue de fonctionner exactement comme en plot classique ; seul un clic sur le bouton est intercepté, les clics ailleurs lancent une rotation comme d’habitude.

Tracé d’une évolution

L’objet Evolution / SubEvolution expose plot(...), qui s’adapte au type de valeur tabulée :

  • évolution de scalaires → une courbe X-Y : l’abscisse est la variable, l’ordonnée la valeur. Un agrégat à plusieurs zones trace une ligne par zone avec légende ; chaque échantillon tabulé est marqué d’un point. Les libellés se règlent par x_label / y_label / title. Les arguments de champ (mesh, component, cmap, …) sont sans effet ici.
  • évolution de champs → le champ est rendu comme par mesh.plot(field=...), pour une valeur tabulée à la fois. La géométrie suit la même règle que le tracé d’un champ seul : champ par éléments → reconstruit son maillage via le support EF ; champ aux nœuds → nuage de points par défaut, ou surface si on passe mesh=<maillage>.
import pyrucast as pc

# Courbe scalaire (variable → valeur).
e = pc.Evolution([(0.0, 10.0), (1.0, 20.0), (2.0, 5.0)])
e.plot(save="courbe.svg", x_label="temps", y_label="T", title="évolution de T")

# Evolution of a field at the nodes: one whole NodeField per time step.
ev = pc.Evolution([(0.0, champ_t0), (1.0, champ_t1), (2.0, champ_t2)])
ev.plot(save="frame.png", frame=2)  # one tabulated value (default: the last)
ev.plot(save="frame_surf.png", mesh=maillage)  # surface rendering on a supplied mesh

Slider de valeur tabulée (fenêtre interactive)

En mode interactif (viz-interactive, save=None), une évolution de champs ouvre la fenêtre avec un slider dessiné en bas, qui choisit quelle valeur tabulée est affichée (le libellé indique frame k/n x=…) :

  • glisser le curseur du slider à la souris ;
  • touches ← / → pour reculer / avancer d’un pas tabulé.

Le bouton de composante (clic / Tab) et la caméra (rotation, molette, axes via A) fonctionnent comme d’habitude ; un clic sur le slider est intercepté, ailleurs c’est une rotation. Le slider ne choisit que parmi les valeurs tabulées — il n’interpole pas entre elles (pour une valeur intermédiaire, voir interpolate sur la page Évolution).

Toutes les sous-évolutions d’un agrégat tracé doivent partager la même grille d’abscisses (un index de frame global l’exige) ; sinon plot lève une erreur.

Types d’éléments rendus

Tous les types d’éléments sont rendus, chacun converti en une primitive géométrique :

TypePrimitiveRendu
POI1pointun point coloré
SEG2segmentune arête
TRI3facetriangle plein + contour noir
QUA4facequadrangle plein + contour noir
TET4faces triangulairesla peau du volume (facettes de bord)
HEX8faces quadrangulairesla peau du volume (facettes de bord)

Mesh.plot parcourt tous ses sous-maillages et dessine chacun selon son type. L’ajout d’un éventuel nouveau type d’élément se fera sans changement d’API, en étendant le match de submesh_primitives dans src/viz/mesh_draw.rs.

Maillages volumiques pleins

Pour un sous-maillage volumique (TET4 / HEX8), seules les facettes de bord sont dessinées : une facette partagée par deux éléments est intérieure au solide, jamais visible, et donc supprimée (une facette est de bord quand elle n’apparaît que dans un seul élément). Cela rend un maillage volumique plein comme une surface fermée opaque au lieu d’un enchevêtrement de toutes les facettes internes, et divise à peu près par deux le nombre de primitives.

Les facettes sont tracées opaques : combinées au tri en profondeur de l’algorithme du peintre, elles réalisent l’élimination des faces cachées (une facette proche recouvre intégralement celles derrière elle), si bien qu’on ne voit que la peau tournée vers la caméra. La suppression des faces intérieures s’applique aussi bien au tracé géométrique qu’à la coloration par un champ (plate et interpolée), de sorte qu’un champ sur un maillage volumique colore correctement sa surface externe.

Peau opaque ou fil de fer

Pour le tracé d’un maillage seul (sans champ), deux styles sont disponibles :

StyleRendu
Surface (défaut)la peau externe opaque ; l’intérieur est masqué
Wireframetoutes les arêtes en fil de fer (y compris les arêtes intérieures des volumes), sans remplissage — un tracé transparent

Le fil de fer trace chaque arête distincte (les arêtes partagées par plusieurs cellules ne sont dessinées qu’une fois) dans la face_color du sous-maillage, donc les composants d’un Mesh restent distinguables. Ce choix n’a pas de sens pour la coloration par un champ (un champ peint toujours les faces) : le combiner avec field lève une erreur.

Côté Rust, le style est un argument [MeshStyle] passé à plot_styled :

#[test]
fn peau_opaque_ou_fil_de_fer() -> Result<()> {
    let (dossier, _, mesh) = scene()?;

    // Peau opaque (équivalent de plot).
    mesh.plot_styled(
        View::iso(),
        Some(&dossier.join("solide.svg")),
        MeshStyle::Surface,
        None, // titre
    )?;
    // Wireframe: every edge.
    mesh.plot_styled(
        View::iso(),
        Some(&dossier.join("fil.svg")),
        MeshStyle::Wireframe,
        None, // titre
    )?;
    Ok(())
}

Côté Python, c’est l’argument booléen wireframe de plot :

mesh.plot(save="solide.svg")  # peau opaque (défaut)
mesh.plot(save="fil.svg", wireframe=True)  # fil de fer

# Pointless with a field: raises ValueError.
# mesh.plot(save="x.svg", field=t_field, wireframe=True)

Axisymétrie : section méridienne ou corps de révolution

Un maillage bâti sur des coordonnées axisymétriques est le demi-plan méridien (r, z) d’un corps de révolution : tracé tel quel, il se lit comme une section plane 2-D — ce qui est fidèle à l’objet calculé, mais peu parlant pour montrer la pièce.

L’option revolve balaie cette section autour de l’axe r = 0 et dessine le solide qu’elle décrit. Rien n’est recalculé : le balayage a lieu sur les primitives de rendu, juste avant la projection, donc il s’applique de la même façon au maillage seul, au fil de fer, à la coloration par un champ (plate et interpolée) et aux évolutions.

ArgumentDéfautEffet
revolveFalseTrue ⇒ trace le corps de révolution au lieu de la section
revolve_angle360.0angle balayé en degrés, dans ]0, 360]

Un angle partiel ouvre la pièce et dessine la section méridienne — et le champ qui la colore — aux deux extrémités du balayage, comme une coupe.

import pyrucast

coords = pyrucast.Coords.axisymmetric()  # (r, z), r ≥ 0
section = [coords.add_node(p) for p in ([1.0, 0.0], [2.0, 0.0], [1.0, 1.0])]
mesh = pyrucast.Mesh(coords, "TRI3")
mesh.unit().add_cell(section)
# … computation, then a temperature field at the section's nodes:
t_field = pyrucast.NodeField(pyrucast.mesh.poi1_from_nodes(section), ["T"])

mesh.plot(save="section.svg")  # la section plane (défaut)
mesh.plot(save="piece.svg", revolve=True)  # le corps de révolution complet
mesh.plot(save="coupe.svg", revolve=True, revolve_angle=270.0)  # opened to 270°
mesh.plot(save="t3d.svg", field=t_field, revolve=True)  # field on the body

Côté Rust, c’est le champ revolve de la View, portant un [Revolve] :

#[test]
fn le_corps_de_revolution_se_demande_dans_la_vue() -> Result<()> {
    // The mesh must be **axisymmetric**: it is its frame that gives
    // l'axe autour duquel le balayage tourne.
    let (dossier, mesh) = section_axisymetrique()?;

    let vue = View {
        revolve: Some(Revolve::full()),
        ..View::iso()
    };
    mesh.plot(vue, Some(&dossier.join("piece.svg")))?;

    // Partial sweep, or angular fineness picked by hand.
    let _ = Revolve::new(270.0).unwrap(); // un secteur par 10°
    let _ = Revolve::with_sectors(360.0, 72).unwrap(); // silhouette plus lisse
    Ok(())
}

Demander revolve sur une géométrie non axisymétrique est une erreur : l’abscisse n’y est pas un rayon, le balayage n’aurait aucun sens.

Ce qui est dessiné

Seul ce qui est visible est émis, l’algorithme du peintre faisant le reste :

  • une face balaie un anneau de matière. Seules les arêtes de bord de la section engendrent une surface latérale : une arête partagée par deux cellules reste enfouie dans la matière. C’est le pendant exact de la suppression des facettes intérieures des maillages volumiques ;
  • le contour d’élément du rendu interpolé suit la même règle, si bien que le quadrillage du maillage reste tracé sur la surface balayée ;
  • segments et points sont répétés à chaque station angulaire, et les cercles décrits par leurs extrémités sont ajoutés : c’est le fil de fer (resp. le nuage de nœuds) du maillage balayé ;
  • une arête posée sur l’axe (r = 0) ne balaie rien ; une arête qui le touche par une extrémité balaie un cône (triangles au lieu de quadrangles).

Bascule dans la fenêtre interactive

En mode interactif, sur une géométrie axisymétrique uniquement :

  • un bouton en haut à gauche indique l’état courant (2D section / 3D 360deg) et bascule au clic ;
  • touche R — même effet, sans toucher à la souris.

La caméra se recentre à chaque bascule : le corps balayé est centré sur l’axe, la section ne l’est pas, sans quoi la pièce sortirait du cadre.

Export vers ParaView (export_vtk)

Pour les maillages industriels — ou simplement pour exploiter les filtres de ParaView — export_vtk écrit un fichier VTK legacy (UNSTRUCTURED_GRID) que ParaView lit nativement. C’est l’opérateur d’export (src/ops/export), pendant « écriture » du lecteur read_gmsh.

import pyrucast

# Géométrie seule.
pyrucast.export.export_vtk(mesh, "maillage.vtk")

# Geometry + field at the nodes (POINT_DATA).
pyrucast.export.export_vtk(mesh, "solution.vtk", field=temperature)

# Geometry + field at the Gauss points (CELL_DATA): one value per cell = the
# intra-element mean of the cell's Gauss points.
pyrucast.export.export_vtk(mesh, "contraintes.vtk", field=stresses)
  • Chaque sous-maillage est écrit ; les types d’éléments se traduisent un pour un (POI1→VERTEX, SEG2→LINE, TRI3→TRIANGLE, QUA4→QUAD, TET4→TETRA, PYRA5→PYRAMID, PENTA6→WEDGE, HEX8→HEXAHEDRON, et leurs variantes quadratiques) et l’ordre local des nœuds coïncide déjà avec celui de VTK : la connectivité est copiée telle quelle.
  • Une Coords 2-D est complétée en 3-D avec z = 0.
  • Un NodeField donne un tableau SCALARS par composante aux points (valeur nodale, 0 là où le champ n’est pas défini) ; un ElementField donne un tableau par composante aux cellules. La valeur par cellule est la moyenne des points de Gauss de cette cellule (moyenne intra-élément uniquement — les discontinuités inter-éléments restent visibles). Le champ aux éléments doit couvrir toutes les cellules du maillage : il provient d’un espace bâti sur ce maillage.

L’écrivain VTK n’est qu’un formateur : la mise à plat (quels points, quelles cellules dans quel ordre, quelles valeurs) vient de to_arrays, la sortie que partagent tous les échanges (voir to_arrays). Les fichiers ASCII produits sont identiques, octet pour octet, à ceux de la première version.

Binaire

binary=True écrit les mêmes sections, les nombres en binaire brut gros-boutiste comme l’exige le format legacy : fichier bien plus petit, lecture bien plus rapide pour un gros maillage.

# Same file, numbers written raw (big-endian): smaller, faster to read.
pyrucast.export.export_vtk(mesh, "solution_bin.vtk", field=t1, binary=True)

Séries temporelles

Passée une Evolution de champs, export_vtk écrit une série : un fichier par valeur tabulée, nom_0000.vtk, nom_0001.vtk…, et un index nom.vtk.series (JSON) qui donne à chacun son temps, l’abscisse de l’évolution. ParaView ouvre l’index comme un seul jeu de données muni d’un curseur de temps. Le maillage n’est mis à plat qu’une fois pour tous les pas.

# An Evolution of fields: one file per time, plus an index for ParaView.
chauffe = pyrucast.Evolution([(0.0, t0), (30.0, t1)])
pyrucast.export.export_vtk(mesh, "chauffe.vtk.series", field=chauffe, binary=True)
# → chauffe_0000.vtk, chauffe_0001.vtk and chauffe.vtk.series:
#   open the .series in ParaView, the time slider plays the two steps.
# The steps themselves, as whole fields:
print(chauffe.shared_abscissas(), len(chauffe.frames()))  # [0.0, 30.0] 2

Côté Rust : ops::export::write_vtk_mesh, write_vtk_node_field, write_vtk_element_field (avec un VtkEncoding), write_vtk_series, et les variantes vtk_*_string qui rendent le texte ASCII sans toucher au disque.

Limites actuelles et évolutions possibles

  • VTK legacy uniquement. Pas de .vtu (XML) ni de compression. Évolution : un back-end .vtu, recommandé par ParaView.
  • Composantes en scalaires séparés. Chaque composante donne un tableau SCALARS distinct ; pas de regroupement en VECTORS/TENSORS. Un déplacement (ux, uy, uz) sort en trois scalaires plutôt qu’en un champ vectoriel directement « warpable » dans ParaView.
  • CELL_DATA = moyenne des points de Gauss. VTK ne connaît pas de donnée au point d’intégration. Pour garder les points de Gauss, passer par MED (to_medcoupling, ON_GAUSS_PT).
  • Un champ par fichier. Plusieurs champs simultanés dans un même fichier restent à faire.

Échanger avec gmsh et Salome (to_gmsh, to_medcoupling)

Les deux sens des échanges passent par le même format à plat. Pour la lecture, voir from_gmsh et from_medcoupling.

to_gmsh pousse maillages et champs dans la session gmsh en cours, comme un nouveau modèle : chaque clé devient un groupe physique, chaque champ une vue — NodeData pour un champ aux nœuds, ElementData (moyenne par maille) pour un champ aux éléments, un pas par valeur d’une Evolution. gmsh dessine 1, 3 ou 9 composantes : un champ d’un autre nombre donne une vue par composante.

# A new gmsh model: the meshes as physical groups, the fields as views.
pyrucast.export.to_gmsh({"piece": piece}, {"T": temperature}, model_name="calcul")
gmsh.write("calcul.msh")  # or gmsh.fltk.run() to look at it
gmsh.view.write(gmsh.view.getTags()[0], "temperature.pos")

# from_gmsh reads the views back: a NodeData view is a NodeField.
_, champs = pyrucast.mesh.from_gmsh(pyrucast.Coords(dim=3))
print(sorted(champs))  # ['T']

to_medcoupling construit un medcoupling.MEDFileData, qu’on écrit avec sa propre méthode write : chaque clé devient un groupe MED (un maillage POI1, un groupe de nœuds), un champ aux nœuds ON_NODES, un champ aux éléments ON_GAUSS_PT avec sa règle déclarée dans l’élément de référence MED (gauss=False : ON_CELLS, la moyenne par maille), un profil quand le champ ne couvre pas tout un niveau, une Evolution un pas de temps par valeur.

# Each key becomes a MED group; an Evolution gives one time step per value.
chauffe = pyrucast.Evolution([(0.0, froid), (60.0, temperature)])
donnees = pyrucast.export.to_medcoupling(
    {"plaque": plaque, "bas": bas},
    {"T": chauffe, "sxx": contraintes},  # sxx: ON_GAUSS_PT
    mesh_name="plaque",
)
donnees.write("plaque.med", 2)  # a medcoupling object: its own writer

Limite de medcoupling pour TRI6, PENTA15 et HEX20 aux points de Gauss. Les éléments de référence par défaut de medcoupling ne suivent pas, pour ces trois types, sa propre connectivité MED (le TRI6 répète un sommet, les deux autres rangent leurs nœuds milieux autrement), et son localisateur de points de Gauss n’en accepte pas d’autres. pyrucast déclare l’élément cohérent avec la connectivité écrite : le fichier est juste et se relit dans pyrucast, mais getLocalizationOfDiscr() de medcoupling le refuse. Les douze autres types sont localisés exactement par medcoupling.

Ni gmsh ni medcoupling ne sont des dépendances : import pyrucast ne charge aucun des deux, ils ne sont importés qu’à l’appel de ces fonctions.

Notes techniques

  • Le rendu utilise l’algorithme du peintre : projection 3D → 2D, tri des triangles par profondeur moyenne (du plus lointain au plus proche), puis dessin des facettes pleines opaques suivies des arêtes noires en superposition. L’opacité assure l’élimination des faces cachées (les facettes proches recouvrent les lointaines) ; c’est ce qui fait qu’un solide 3D se lit comme un solide et non comme une coque transparente. Coût : O(n log n) à chaque rafraîchissement, raisonnable jusqu’à quelques milliers de cellules. Pour des maillages plus lourds ou un post-traitement avancé, exporter vers ParaView avec export_vtk (voir ci-dessus).
  • Limite connue de l’algorithme du peintre : pour un solide fortement non convexe, le tri par profondeur moyenne peut mal ordonner deux facettes qui se chevauchent en profondeur. C’est inhérent à la méthode ; un z-buffer par pixel le corrigerait, au prix d’un rendu non vectoriel.
  • Le balayage axisymétrique (revolve) multiplie le nombre de primitives par le nombre de secteurs, mais seulement sur le bord de la section (les arêtes intérieures ne balaient rien) : le coût reste proportionnel au périmètre, pas à la surface. Une section partielle ajoute en plus une copie de la section à chaque extrémité.
  • L’export reste portable Linux ↔ Windows : tout le rendu se fait en CPU, sans pilote GPU. Le binaire viz-interactive nécessite en revanche un serveur d’affichage (X11, Wayland ou Windows) à l’exécution — ce qui est attendu pour une fenêtre interactive.
  • Le mode interactif est confiné à src/viz/window.rs ; il est entièrement encapsulé derrière la feature viz-interactive et ne s’invite pas dans la couche de calcul.

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érateur thermal_strain, Cast3M EPTH), 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 (historique m = 3, garde-fou de descente). L’état interne (plasticité, endommagement…) est propagé d’un pas au suivant via l’interface incrémentale integrate_behavior(..., prev=…, dt=…).

Les trois fonctions

FonctionRôle
step_by_step(data) -> dictMise 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) -> NodeFieldUne 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éTypeRôle
timeslist[float]instants de calcul
modelModelmodèle complet (thermique + mécanique + Dirichlet). L’espace EF et le maillage en sont déduits — voir ci-dessous
loadsNodeField | Evolutionun seul champ unioné : q/imposed_T (thermique) + f_*/imposed_u (mécanique)
materialsElementField | Evolutionun seul champ unioné : k/h + E/nu/alpha
t_reffloat (opt.)température de référence pour ε_th
free_meshMesh (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

  1. Présentation de pyrucast — ce qu’est pyrucast, ce qu’il fait, comment l’installer.
  2. Python & conventions pyrucast — l’équivalent du chapitre « langage Gibiane » : objets, opérateurs, conventions de nommage.
  3. Maillage — mailleur non structuré (triangulation avec trou) et mailleur structuré (balayage).
  4. Calcul thermique — conduction, flux imposé, convection, source volumique, et le repérage géométrique des régions chargées.
  5. Calcul mécanique — élasticité linéaire, dilatation thermique, plasticité parfaite (pas à pas), contact unilatéral.
  6. 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 ?

  1. Écrire un script Python — un fichier texte ordinaire, extension .py.

  2. Ouvrir un terminal, se placer dans le dépôt cloné.

  3. Compiler puis lancer le script :

    maturin develop --release
    python mon_script.py
    
  4. Utilisable aussi en mode interactif (python, ou un notebook) — chaque import pyrucast recharge 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èmeCast3M (le plus proche)
pc.mesh.line, pc.mesh.triangulate_surface, pc.mesh.sweep…maillageDROITE, SURF, VOLU, TRAN
pc.element_field.gradient, pc.mesh.select, pc.node_field.mask…champsGRAD, MASQUE
pc.matrix.stiffness, pc.matrix.mass, pc.node_field.external_forces…assemblageRIGI, MASS, FLUX/PRES
pc.element_field.integrate_behaviorcomportementCOMP
pc.solver.solve, pc.solver.solve_unilateralsolveurRESO
pc.element_field.material_fieldconstructionMATE
pc.export.export_vtkexportSORT '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 cibleplace ses propres nœuds à l’intérieurquelconque
structuréun nombre d’élémentsbalaie une ligne sur une autregrille

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_volume prévient sur stderr et les nomme : le maillage renvoyé contient un second sous-maillage, de POI1, à côté des TET4. element_types() vaut donc ['TET4', 'POI1'] et non ['TET4']. Tout ce qui parcourt les sous-maillages ou compte des mailles doit prendre le TET4 seul. Sur la figure ci-dessus, les tétraèdres sont en noir et ce sont ces nœuds ajoutés (3 sur cette pièce) que l’on voit en rouge.

Maillage structuré : grille et couronne

Un nombre d’éléments, plus une taille

def structured_mesh(plot: bool = True) -> tuple[pc.Mesh, pc.Mesh]:
    # FR — Plus de taille de maille : un nombre d'éléments par direction.
    # EN — No element size any more: an element count per direction.
    n15 = 10  # FR — éléments sur la hauteur / EN — elements through the height
    n12 = 20  # FR — éléments sur la longueur / EN — elements along the length

En structuré on ne donne plus une taille de maille mais un nombre d’éléments par direction. La pièce est traitée en deux morceaux : une grille rectangulaire à gauche, une couronne autour du trou à droite.

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 :

Termepyrucast
\( \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.restrict sur un même maillage retombe sur le support POI1 canonique de ce maillage, donc restrict(a, m) + restrict(b, m) est bien une somme nœud à nœud (la page Champs détaille cette algèbre ; le chapitre 3 en donne un exemple avec restrict_like).

    # FR — Les trois charges se touchent : support commun, puis `+` somme.
    # EN — The three loads touch: a common support first, then `+` really sums.
    noeuds_charges = pc.mesh.consolidate(
        pc.mesh.to_poi1(face_gauche | face_basse | zone_source)
    )
    second_membre = (
        pc.node_field.restrict(flux_gauche, noeuds_charges)
        + pc.node_field.restrict(charge_convection, noeuds_charges)
        + pc.node_field.restrict(charge_source, noeuds_charges)
    ) | temperature_imposee

D’où ces quelques lignes : le maillage POI1 de tous les nœuds chargés (to_poi1 puis mesh.consolidate, pour n’avoir qu’un seul sous-maillage donc un seul support), les trois champs restreints dessus, et leur somme par +. La vérification est immédiate — la puissance totale du second membre 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). Un Evolution peut 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) :

  1. pyrucast.element_field.thermal_strain : \( \varepsilon_{\text{th}} = \alpha \cdot (T - T_{\text{ref}}) \), la même formule que Cast3M EPTH, à partir d’un champ de température aux points de Gauss (pyrucast.element_field.interp_to_gauss) ;
  2. pyrucast.element_field.integrate_behavior : la pseudo-contrainte thermique \( \sigma_{\text{th}} = D : \varepsilon_{\text{th}} \) ;
  3. pyrucast.node_field.internal_forces : la charge nodale équivalente \( F_{\text{th}} = \int B^T \sigma_{\text{th}} \, dV \) (Cast3M BSIG).

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 : un ElementField accepte des valeurs non uniformes par (cellule, point de Gauss), et pyrucast.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_mesh restreint la norme aux degrés de liberté réellement libres (ici : tous les nœuds d’abscisse x > 0). C’est l’équivalent, en plus explicite, du traitement automatique des blocages par Cast3M dans RESO/PASAPAS.

Non disponible dans pyrucast. model.plasticity_perfect ne consomme pas encore la composante matériau optionnelle alpha — la dépendance de sigma_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.

pyrucastCast3Mprimales / 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 (Cast3M OPTI 'MODE' 'AXIS') ni de configuration purement 1D (OPTI 'DIME' 1) — Coords est 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 maillage SEG2/TRI3/QUA4 selon une direction, dans le même espace de coordonnées (Cast3M TRAN/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é (Cast3M ROTA/VOLU 'ROTA') ;
  • pyrucast.mesh.sweep_solid(mesh_a, mesh_b, n_couches) — balayage entre deux profils TRI3/QUA4 non parallèles (Cast3M REGL + VOLU) ;
  • pyrucast.mesh.triangulate_volume(enveloppe, taille) — remplissage TET4 d’une enveloppe TRI3 fermée par triangulation de Delaunay 3D (Cast3M VOLU par 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 (Cast3M OPTI '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 de Coords, et la mécanique de l’archive.
  • Parallélisme — rayon porté au-dessus des noyaux, zéro-copie, déterminisme, et ce qui reste séquentiel.
  • Compilation et tests — installation détaillée, features Cargo, génération du stub .pyi, script « tout-en-un », dépannage.
  • Ajouter une physique — le coût en O(1) fichier d’une nouvelle variante de SubModel / SubModelKind.
  • Ajouter un élément fini — un nouveau ElementType, son interpolation et sa quadrature.
  • Interrompre une fonction — le jeton Cancel : un Ctrl+C ou un timeout qui arrête une boucle longue.

Arborescence

La carte des sources : où vit chaque morceau. La règle d’or est la séparation conteneurs / opérateurs / binding — les structures de données (containers/) ne connaissent pas les opérateurs (ops/), et le binding Python (py/) est un miroir 1:1 qui n’ajoute aucune logique.

Deux découpages secondaires en découlent, et l’arborescence les rend visibles : les types indivisibles vivent dans atoms/ (seul un conteneur peut être le sujet d’un opérateur), et chaque module d’ops/ porte le nom du conteneur qu’il produit.

Le dépôt tient deux paquets. À côté de la bibliothèque, macros/ porte les macros procédurales — aujourd’hui #[py_op], qui dérive d’un opérateur libre la méthode de son sujet. Cette séparation n’est pas un choix de rangement : une crate proc-macro ne peut rien exporter d’autre que des macros, et une crate ordinaire ne peut pas en contenir.

macros/
└── src/lib.rs          # `#[py_op]` : receveur déduit du sujet, signature pyo3
                        #   amputée de son entrée de tête, `#[doc]` et
                        #   `#[allow]` recopiés sur la méthode
src/
├── lib.rs              # racine de la crate + #[pymodule] (enregistrement Python)
│
├── handle.rs           # Handle<T> : Arc<RwLock<T>>, guards possédés, identité
├── persist.rs          # trait Portable (serde + bincode), format portable
├── error.rs            # PyrucastError + Result (l'unique type d'erreur)
├── dump.rs             # trait Dump (3ᵉ niveau d'affichage : contenu intégral)
├── aggregate.rs        # trait Aggregate + macros (len/[i]/union, pyméthodes)
├── parallel.rs         # prelude rayon + politique de grain (MIN_PARALLEL_LEN)
├── interrupt.rs        # trait Cancel + jetons (NoCancel, AtomicBool, Deadline)
│
├── coords.rs           # LE MAGASIN : Coords (jeux de coordonnées, refcount par nœud)
│
├── atoms/              # LES INSÉCABLES (jamais sujet d'un opérateur)
│   ├── mod.rs
│   ├── node.rs         # Node (accesseur RAII) + NodeId
│   ├── cell.rs         # Cell (désigne une maille d'un SubMesh)
│   ├── element.rs      # Element (désigne un élément d'un SubFiniteElementSpace)
│   ├── element_type.rs # enum ElementType (stockage, sérialisation, ALL, as_kind)
│   ├── element_kind/   # UN FICHIER PAR ÉLÉMENT + le trait qui les lie
│   │   ├── mod.rs          # trait ElementKind, Facet, as_kind()
│   │   ├── interpolation.rs# enum Interpolation (façade sur ElementKind::degree)
│   │   ├── quadrature.rs   # enum QuadratureRule (façade + briques partagées)
│   │   └── tri3.rs, tri6.rs, hex8.rs, …  # un par type d'élément
│   ├── point.rs        # Point2 / Point3 / Vector2 / Vector3 (nalgebra)
│   ├── band.rs         # Band (bande de valeurs ge/gt/le/lt — mask, select)
│   └── color.rs        # RgbColor (couleur de face, viz)
│
├── containers/         # LES DIVISIBLES (structures de données, aucune dépendance à ops/)
│   ├── mod.rs
│   ├── mesh.rs         # Mesh, SubMesh
│   ├── finite_element_space/
│   │   └── mod.rs          # FiniteElementSpace, SubFiniteElementSpace
│   ├── field.rs        # traits Field / SubField (contrat commun des champs)
│   ├── node_field.rs   # NodeField / SubNodeField
│   ├── element_field.rs# ElementField / SubElementField
│   ├── model.rs        # Model / SubModel (enum de stockage + dispatch)
│   ├── matrix.rs       # Matrix / SubMatrix (matrice creuse COO)
│   └── evolution.rs    # Evolution / SubEvolution (valeur tabulée, interpolée)
│
├── models/             # LES PHYSIQUES (une struct + impl SubModelKind par fichier)
│   ├── mod.rs              # traits SubModelKind / Domain / Constraint, MatrixKind
│   ├── kernel.rs           # LES DRIVERS parallèles au-dessus des noyaux purs
│   ├── heat_conduction.rs
│   ├── boundary_transfer.rs # échange de surface avec une ambiante (Robin / film)
│   ├── transfer.rs         # le noyau h∫NiNj partagé par les deux échanges
│   ├── continuum/          # LA MODÉLISATION continue, partagée par les trois
│   │   │                   #   familles mécaniques — pas une physique
│   │   ├── mod.rs          #     Continuum + les noyaux K, M, Kg, Kt
│   │   ├── voigt.rs        #     nomenclature ε / σ / D_alg, lecture par indice
│   │   ├── elastic.rs      #     l'opérateur élastique (D, λ, μ) — prédicteur
│   │   │                   #     de la plasticité, module sain de l'endommagement
│   │   ├── material.rs     #     MatRead : la ligne matériau lue par position
│   │   └── internal_force.rs #   Bᵀσ (BSIG), aussi appelé sans sous-modèle
│   ├── truss.rs
│   ├── elasticity.rs       # LA physique élastique, pour toute loi sans état
│   ├── elasticity/         #   ElasticLaw (identité) + StatelessLawKind
│   │   └── linear.rs       #     σ = D:ε
│   ├── plasticity.rs       # LA physique élastoplastique, pour toute loi
│   ├── plasticity/         #   la machinerie des lois d'écoulement…
│   │   ├── law.rs          #     PlasticLaw (identité) + ReturnMapLawKind
│   │   └── von_mises.rs, drucker_prager.rs, ottosen.rs, viscous.rs, gurson.rs
│   ├── damage.rs           # LA physique d'endommagement, pour toute loi
│   ├── damage/             #   DamageLaw (identité) + DirectUpdateLawKind,
│   │   │                   #   puis une loi par fichier
│   │   └── mazars.rs, damage_tc.rs, sic_sic.rs
│   ├── timoshenko.rs
│   ├── frame.rs           # portique 2D
│   ├── frame3d.rs         # cadre 3D
│   ├── dirichlet.rs       # contrainte (multiplicateurs de Lagrange)
│   ├── mpc.rs             # relation multi-points
│   ├── embedded.rs        # baignage (nœuds immergés dans un hôte)
│   └── contact.rs         # contact nœud-surface (unilatéral)
│
├── ops/                # LES OPÉRATEURS — un module par conteneur produit
│   ├── mod.rs
│   ├── mesh/           # → Mesh : mailleurs, transformations, select
│   │   ├── triangulation/  # briques 2D Delaunay (ear clipping, CDT, Ruppert)
│   │   ├── tetrahedralization/  # briques 3D Delaunay (prédicats exacts, …)
│   │   ├── paving/         # front avançant 2D (pave_surface)
│   │   └── plaster/        # front avançant 3D (pave_volume)
│   ├── node_field/     # → NodeField : positions, divergence, restrict, flux, …
│   ├── element_field/  # → ElementField : gradient, deformation, material_field
│   │   └── behavior.rs     # intégration de la loi de comportement (COMP)
│   ├── model/          # → Model : les déclarations de physique. Les formes
│   │                   #   courantes sont déclarées dans models/<nom>.rs et
│   │                   #   seulement ré-exportées ici ; les contraintes 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

CoucheRôleNe dépend pas de
containers/structures de données + invariantsops/, py/
ops/opérateurs croisant des conteneurspy/
py/exposition Python (PyO3), miroir 1:1—
python/pyrucast/rangement du module plat _pyrucast en sous-modules—

Cette stratification garde le cœur de calcul testable sans Python (cargo test) et réutilisable en pure crate Rust. Le models/ est à part : ce sont les physiques, branchées sur les conteneurs (Model/SubModel) via le trait SubModelKind — voir Ajouter une physique.

Où ajouter quoi ?

Pour ajouter…Toucher principalement
un type d’élémentatoms/element_kind/<nom>.rs + 2 lignes dans atoms/element_kind/mod.rs + 2 dans atoms/element_type.rs (guide)
une physiquemodels/<nom>.rs (physique, tests et opérateur via physics_operator!) + 2 lignes dans containers/model.rs + le raccord pub use / add_function (guide)
un opérateurops/<conteneur produit>/<nom>.rs + son wrapper py/ops/<même module>.rs + son ré-export dans python/pyrucast/<même module>.py
un objet conteneurcontainers/<nom>.rs + py/<nom>.rs + son ré-export dans python/pyrucast/__init__.py + un chapitre de doc

Le troisième site est facile à oublier. Une fonction libre exposée par PyO3 atterrit dans le module plat _pyrucast ; tant qu’elle n’est pas ré-exportée dans python/pyrucast/<module>.py, elle n’existe pas dans l’API publique. Les homonymes y reprennent au passage leur vrai nom (consolidate_mesh as consolidate) — voir Conventions & philosophie.

Conventions & philosophie

Trois natures de types

L’arborescence sépare trois choses que le mot « objet » confond, et c’est elle qui rend la convention lisible sans qu’on ait à la relire :

  • conteneurs (containers/) — ce dont une partie est encore de la même nature : la moitié d’un maillage est un maillage, la moitié d’un champ est un champ. Les sept agrégats et leurs vues Sub*. Seul un conteneur peut être le sujet d’un opérateur ;
  • atomes (atoms/) — les insécables : Node, Cell, Element (désignateurs), ElementType, Point3, RgbColor (valeurs). Un atome compose (node | node → Mesh) mais ne se décompose pas ; il est donc toujours argument, jamais self. Piège à connaître : Cell s’indexe (cell[k] → Node) sans être un conteneur, car ses parties sont d’une autre nature ;
  • magasin (coords.rs) — Coords, ni conteneur ni atome : il contient des nœuds, d’une autre nature que lui, et n’offre ni [], ni |, ni vue Sub*.

Méthodes vs fonctions libres

La règle qui décide si une opération est une méthode d’un conteneur ou une fonction libre d’un module ops/ est figée dans le fichier CONVENTIONS.md à la racine du dépôt. En résumé :

  • méthode — accesseur, mutation préservant l’invariant, ou vue dérivée d’un seul conteneur (mesh.cell_count(), field.set(...), field.components()) ;
  • fonction libre ops::<module> — tout opérateur qui croise des conteneurs ou appartient à une famille d’opérateurs (ops::node_field::restrict(field, mesh), ops::matrix::stiffness(model, mat), ops::mesh::consolidate(mesh)).

Un constructeur nommé échappe aux deux : il fabrique son propre type, il reste donc sur le type (FiniteElementSpace.lagrange1(mesh), Matrix.block(...)). Mais l’exception s’arrête au pluriel : une famille qui grossit — le catalogue de physiques d’un Model, 28 entrées — se range comme toute famille d’opérateurs, dans le module du conteneur produit. Deux raisons, l’une de principe, l’autre très concrète : en Python une classmethod s’atteint aussi depuis une instance, si bien que m.heat_conduction(fes) s’exécute en jetant m en silence, et l’auto-complétion d’un objet se remplit de verbes qui ne s’adressent pas à lui. D’où pyrucast.model.heat_conduction(fes) et non Model.heat_conduction(fes) (rupture du 2026-08-25).

Où vit une fonction libre : le conteneur produit

Un module d’ops/ rassemble les opérateurs qui produisent le même conteneur, et porte son nom. On range par la sortie, jamais par l’entrée : gradient(field, fespace) produit un ElementField, donc il vit dans element_field, à côté de deformation — pas dans node_field.

moduleproduit
meshun Mesh (mailleurs, transformations, select)
node_fieldun NodeField (dérivations, assemblage nodal)
element_fieldun ElementField (cinématique, matériaux, comportement)
matrixune Matrix (les assembleurs)
modelun Model (les déclarations de physique)
coordsécrit dans le magasin (set, displace)

Ceux qui ne produisent aucun conteneur échappent à la règle par construction et se rangent par activité : measure (réductions à un nombre), geom (requêtes géométriques), export (effets de bord).

Troisième cas : l’opérateur générique, dont le produit est bien un conteneur mais pas un conteneur déterminé — abs rend un NodeField ou un ElementField selon son argument, la règle ne désigne donc pas un module. Ceux-là se rangent par domaine (field) et restent des fonctions libres à part entière.

Une seule exception, nommée et assumée : solver produit un NodeField et devrait rejoindre node_field. Il garde son nom parce que plusieurs familles distinctes produisent un champ nodal — dérivation, assemblage, résolution — et que seule la résolution se cherche par son propre nom.

Corollaire pratique : un module ne contient jamais deux opérateurs qui ne diffèrent que par leur conteneur. Le qualificatif appartient au nom du module, pas à celui de la fonction — d’où trois consolidate homonymes et sans ambiguïté, au lieu de trois fonctions suffixées au même endroit.

Le verbe exposé aussi en méthode

Une fonction libre garde sa forme canonique — c’est elle qui est documentée et qui définit l’opération. Elle est en plus exposée comme méthode de son premier argument si, et seulement si, les trois conditions tiennent :

  1. le premier argument est le sujet — l’objet qu’on transforme ;
  2. le retour est un conteneur — sinon il n’y a rien à composer ;
  3. l’opération a un sens pour toute instance du type.

La troisième est celle qu’on oublie, et c’est la plus coûteuse : une méthode promet, elle apparaît dans l’auto-complétion de chaque objet du type. u.deformation(fes) s’afficherait sur tous les champs nodaux alors qu’elle exige des composantes u_x/u_y/u_z ; t.thermal_strain(...) sur tous les champs par éléments alors qu’elle exige une température. Ces opérations restent des fonctions libres seules.

La ligne de partage : une précondition structurelle est admise (triangulate_surface veut un contour fermé), une exigence de sens porté par les noms de composantes ne l’est pas — à moins que le nom cherché ne soit un argument, ce qui la rend structurelle à son tour : divergence(field, "sigma") ne promet rien sur field, il dit quoi y chercher.

Pour tester la condition sans se tromper, lire la méthode avec un receveur quelconque, pas avec l’exemple bien nommé : stresses.internal_forces() sonnerait juste, mais c’est le nom de la variable qui ferait le travail — field.internal_forces() révèle que le type ne promet rien. Son sujet est le modèle, qui promet ses physiques, et c’est là que la méthode vit.

# chaining, when the three conditions hold:
peau = maillage.skin().consolidate()
libre = champ.select(ge=0.0)
eps = u.gradient(fes)

# forme canonique seule, sinon :
eps = pyrucast.element_field.deformation(u, fes)  # exige un déplacement
f = pyrucast.node_field.merge(a, b)  # symétrique : `a | b` suffit

Deux conséquences à retenir. Un ordre d’arguments qui ne met pas le sujet en tête est un défaut à corriger, pas une raison de renoncer à la méthode. Et une opération symétrique n’a pas de méthode : a.merge(b) suggérerait que l’ordre compte, alors que merge est l’alias nommé de a | b.

Enfin, le nom peut changer entre les deux formes. Le nom complet est toujours « qualificatif + verbe » ; la fonction libre reçoit le qualificatif de son module, la méthode n’en a pas et doit le porter : matrix.stiffness(model, mats) d’un côté, model.stiffness_matrix(mats) de l’autre. Quand la sortie est du type du sujet il n’y a rien à qualifier, et le nom ne bouge pas (mesh.consolidate(m) / m.consolidate()).

Le miroir Python

Le binding Python est un miroir 1:1 : une fonction Rust devient une fonction Python, une méthode reste une méthode. On vise le style numpy/scipy (et l’héritage cast3m) — des opérateurs nommés plutôt que des chaînes de méthodes. Le module de production est reflété par un sous-module : une fonction libre ops::<module>::f est exposée comme pyrucast.<module>.f. Les conteneurs et les atomes restent des classes au top-level (pyrucast.Coords, pyrucast.Mesh, pyrucast.Node, …) :

import pyrucast

# functions (operators), laid out by produced container — not methods:
poi = pyrucast.mesh.to_poi1(mesh)
coords = pyrucast.node_field.positions(mesh)
eps = pyrucast.element_field.deformation(u, fes)
K = pyrucast.matrix.stiffness(model, materials)
sol = pyrucast.solver.solve(K, rhs)

Le miroir est sans exception : aucune fonction libre ne vit au top-level Python. Les trois consolidations suivent leur module — pyrucast.mesh.consolidate, pyrucast.node_field.consolidate, pyrucast.element_field.consolidate — comme leurs homologues Rust.

L’extension compilée _pyrucast est, elle, plate : les homonymes y portent un #[pyo3(name = …)] distinct (consolidate_mesh, …) et la couche Python pure les ré-exporte sous leur vrai nom dans le bon sous-module. C’est un détail du namespace privé, pas une entorse au miroir.

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 : Portable n’est pas exposé côté Python — c’est une brique interne. La sérialisation depuis Python passe par pyrucast.save / pyrucast.load, qui écrivent un graphe entier plutôt qu’un objet isolé.

Definition of Done par objet

Un objet n’est considéré comme terminé que lorsque les six points suivants sont verts :

  1. Struct Rust adressable par un Handle<T> typé.
  2. Debug (structure) + Display (résumé).
  3. Tests unitaires Rust, et un doctest portant un exemple exécutable sur chaque item public — ignore proscrit, no_run si l’exemple ne peut pas tourner.
  4. Binding PyO3 (__repr__ / __str__).
  5. Tests Python (tests/python/), la surface pyo3 comprise.
  6. Chapitre de cette documentation, dont le code est inclus depuis un test ou un exemple, jamais recopié dans la page.

Les points 3, 5 et 6 sont détaillés page Documentation et tests : quel type d’exemple vit où, et qui le vérifie.

Dépendances approuvées

Le socle figé est :

  • toujours lié — serde + bincode (persistance), nalgebra + nalgebra-sparse (primitives et stockage creux), faer (LU creux du solveur), rayon (parallélisme), parking_lot (verrous des objets), paste (macros d’agrégat) ;
  • optionnel, derrière une feature — pyo3 et pyo3-stub-gen (binding et stub), plotters / winit / softbuffer (visualisation) ;
  • outillage — maturin, mdbook, ruff, criterion (bancs).

Chaque dépendance est confinée à un étage et ne fuit pas hors de lui — le graphe des dépendances dit lequel pour chacune. Toute autre dépendance, Rust ou Python, requiert un accord explicite.

Documentation et tests

Cette page dit quel type d’exemple vit où, ce que chaque type de test prouve, et surtout qui le vérifie. Les règles seules, sans la narration, sont dans CONVENTIONS.md à la racine du dépôt.

Pourquoi une page entière

Parce que le projet s’est fait prendre trois fois, de la même façon.

Une doc qui a menti pendant tout un redécoupage. Le remaniement d’API du 3 août 2026 a renommé ops::assemble en ops::matrix, ops::build en ops::element_field, ops::mesher en ops::mesh. Trois pages du book (model.md, matrix.md, thermique.md) sont restées sur les anciens noms. La suite de tests était verte, la CI aussi, et personne ne l’a vu pendant des semaines — parce que rien, nulle part, ne lisait ces pages.

Un vérificateur qui ne vérifiait rien. mdbook test tournait à chaque check, égrenait 85 chapitres et affichait un verdict. Il testait zéro bloc : les 73 clôtures Rust du book étaient toutes marquées rust,ignore, et ignore veut dire exactement « ne pas compiler ». Le pas était vert parce qu’il ne regardait rien — il a depuis été retiré de la chaîne.

Un garde-fou aveugle, deux fois de suite. L’outil écrit pour compenser est passé au vert alors qu’il ne vérifiait rien : d’abord parce que le fichier qu’il produisait n’était pas analysable — et un fichier qui ne parse pas est vérifié pour rien —, ensuite parce qu’il filtrait sur une liste de codes d’erreur fatals, ce qui laissait passer les erreurs de syntaxe, qui n’ont pas de code. Dans les deux cas il fallait casser volontairement ce qu’il surveillait pour s’en apercevoir.

D’où la ligne directrice de toute cette page :

Un pas de vérification qui passe au vert sans rien regarder coûte plus cher que son absence : il se lit comme de la couverture.

Les types de test

typeoùce qu’il prouvelancé par
test unitaire#[cfg(test)] mod tests dans src/**.rsle comportement d’une unité, publique ou privéecargo test — check_rust
doctestcommentaires /// et //! dans src/**.rsque l’exemple documentant un item compile et tournecargo test --features viz — check_rust
test d’intégrationtests/*.rsune chaîne complète vue de l’extérieur du cratecargo test — check_rust
test Pythontests/python/*.pyla surface pyo3 et le comportement côté Pythonpytest — check_python
garde-foutests/python/test_method_exposure.py, test_mirror_completeness.pyune invariante d’API, pas un comportementpytest — check_python
exempleexamples/*.pyune chaîne utilisateur de bout en boutrun_examples — check_examples
script de formationformation/*.pyun parcours pédagogique completrun_examples — check_examples
bancbenches/*.rsune performance, jamais une correctioncargo bench, hors CI

Trois distinctions valent d’être tenues :

  • Un test unitaire n’est pas un test d’intégration. Le premier a accès au privé et teste une unité ; le second voit le crate comme un utilisateur, par son API publique seulement. Un comportement qui n’est prouvé qu’en unitaire peut être inatteignable de l’extérieur.
  • Un garde-fou n’est pas un test. Il ne vérifie aucun calcul : il vérifie qu’une règle tient encore — que tout opérateur Rust a son binding Python, que tout verbe éligible a sa méthode. Il échoue quand quelqu’un a oublié une projection, pas quand un résultat est faux.
  • Un banc ne remplace jamais un test. Il mesure, il n’affirme rien, et il ne tourne pas en CI. Le dimensionner autour de 0,5 s par itération : en dessous, le bruit de mesure atteint ±18 % et noie le signal ; au-dessus, la mémoire s’envole. Et prendre la référence en basculant de commit avec git, pas de mémoire — puis relancer, pour trier ce qui est du bruit de ce qui est un écart.

Où vit un exemple

C’est l’arbre de décision pratique. On part de ce que l’exemple illustre, jamais de la page où on veut l’afficher.

l’exemple illustre…il vit…le book le montre par…vérifié par
un item de l’API Rustun doctest sur l’itemrien — il est dans la rustdoccargo test --doc
une chaîne Rust complète (une physique)tests/<sujet>.rs, entre ancresun include ancrécargo test
une chaîne Python complèteexamples/<sujet>.pyun include, entier ou ancrérun_examples
un parcours pédagogiqueformation/<sujet>.py, entre ancresun include ancrérun_examples
l’usage d’un opérateur en Pythontests/python/test_doc_<famille>.py, entre ancresun include ancrépytest
ce qui n’est pas du code (signature annotée, pseudo-code)la page elle-même```text — pas de coloration, donc pas de promesserien, et c’est assumé

Un piège vérifié à la première migration : le préfixe test_ n’est pas décoratif. pytest ne collecte que test_*.py ; un fichier de sources d’exemples nommé doc_ops_assemblage.py est inclus dans le book et exécuté par personne. Le garde-fou includes ne peut pas le voir — l’ancre résout très bien —, et on retombe sur une page qui montre du code que rien ne vérifie.

Au niveau module, pas dans une fonction

mdbook n’enlève pas l’indentation d’un extrait inclus. Un bloc ancré à l’intérieur d’une fonction de test s’affiche donc décalé de quatre espaces, sur chaque page, indéfiniment — ce qu’aucun utilisateur n’écrirait. Le code ancré vit par conséquent au niveau module, et le fichier se lit dans l’ordre de la page, les variables coulant d’une ancre à la suivante, comme elles le font pour le lecteur du chapitre.

Trois conséquences, toutes mesurées :

  • pytest exécute le fichier à la collecte. Un exemple qui casse est une erreur de collecte et non un test en échec : le traceback est complet, la réécriture d’assertion fonctionne (assert 450 == 451), et le code de retour vaut 1. --continue-on-collection-errors, posé dans pyproject.toml, évite qu’elle interrompe le reste de la suite ;
  • ces fichiers ne comptent plus de « tests » au sens de pytest. C’est un changement d’affichage, pas de couverture : le code s’exécute et les assertions mordent ;
  • les fixtures ne sont pas disponibles. Ce que tmp_path + monkeypatch.chdir donneraient — écrire des fichiers sous des noms courts sans polluer le dépôt — s’obtient en trois lignes au niveau module : tempfile.TemporaryDirectory(), un os.chdir en tête, et surtout un os.chdir de retour en fin de fichier. Omettre la restitution déplacerait tous les fichiers de test collectés ensuite. C’est le procédé de sauvegarde.md, installation.md et visualization.md.

Doctest et bloc du book ne se confondent pas

C’est la première question qu’on se pose, et la réponse est : ce sont deux choses différentes, on ne cherche pas à les unifier.

Le doctest sert le lecteur de la rustdoc. Il est à côté de la fonction, il répond à « comment j’appelle celle-ci ? », il tient en cinq lignes, et son montage se cache derrière # . Il suit l’item : si la signature change, il casse.

Le bloc du book sert le lecteur du chapitre. Il répond à « comment je monte un calcul thermique ? », il fait quarante lignes, et il n’a de sens que dans l’ordre du récit. Il vit dans un test d’intégration, où il est exécuté pour de vrai.

Deux publics, deux sources. Une chaîne complète recopiée en doctest serait illisible ; un doctest promu en chapitre ne raconterait rien.

include ou rustdoc_include

Le mécanisme d’inclusion se choisit sur une seule question : est-ce que quelque chose compile déjà ce fichier ?

  • Oui (tests/, examples/, 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 que mdbook test le compilerait. Il faudrait alors le remettre dans check_doc, d’où il a été retiré.

Il n’y a aujourd’hui aucun fichier du second cas dans ce dépôt, et il n’y a pas de raison d’en créer : un fichier d’exemple qui mérite d’être compilé mérite d’être un test.

La règle d’or

Aucune page du book ne possède de code.

Toute clôture ```rust ou ```python d’une page contient une directive d’inclusion pointant une source que la CI exécute. Sans exception : ce qui ne peut pas s’exécuter n’est pas du code, et se balise ```text.

Le corollaire pratique : quand on veut ajouter un exemple à un chapitre, on n’écrit pas dans le chapitre. On écrit un test, on l’encadre d’ancres, et on l’inclut. L’exemple devient de la couverture au lieu d’être une dette.

État au 18 août 2026 :

surfaceconformeécrit à la main
blocs Rust du book690
blocs Python du book1880

C’était 22 sur 73 et 61 sur 190 quand ces conventions ont été écrites. Le garde-fou fences empêche désormais d’en réintroduire.

Les doctests

Tout item de l’API publique porte un exemple exécutable. C’est le point 3 de la Definition of Done, et c’est l’étage le moins cher de tous : dans un doctest, le crate est dans la portée automatiquement, cargo gère l’édition de liens, il n’y a ni chemin de bibliothèque à passer, ni ancre à maintenir. On écrit l’exemple à côté de la fonction, et cargo test le vérifie.

Le vocabulaire des attributs, et lequel choisir :

Une exception, et une seule : la méthode de pure délégation. Elle expose la face « sujet » d’un opérateur — aucune logique, un appel à la fonction libre — et c’est cette dernière qui est la forme canonique et qui porte la documentation. Y ajouter un exemple dupliquerait le sien : soixante-treize exemples de plus, plus de six cents lignes dans des fichiers dont tout l’objet est de tenir en une ligne par verbe, et un second texte à faire vieillir.

On les reconnaît à leur marqueur, non à leur emplacement : toute leur documentation tient en un « voir [module::verbe] ». Chercher par répertoire en laissait passer la moitié — il y en a au bas de src/ops/matrix.rs, et d’autres que produit une macro sur impl $T.

attributeffetquand
(aucun)compile et exécutele cas normal, à viser toujours
no_runcompile, n’exécute pasouvre une fenêtre, dure une minute, écrit un fichier
compile_faildoit échouer à compilerdocumenter ce que le typage interdit
should_panicdoit paniquerdocumenter une précondition
ignorene fait rien❌ proscrit

ignore est proscrit sans exception : c’est le marqueur qui ne vérifie rien, et c’est précisément lui qui a désarmé le book 73 fois. S’il paraît nécessaire, c’est que no_run était le bon choix, ou que l’exemple appartient à un test.

Deux points utiles :

  • les lignes de montage se cachent avec # en tête. Elles n’apparaissent pas dans la rustdoc, mais elles sont compilées — c’est ce qui permet à un exemple de trois lignes lisibles de reposer sur dix lignes de Coords, de nœuds et de maillage ;
  • les doctests s’exécutent aussi sur les items privés. La visibilité n’est donc jamais une raison de s’en passer.

Ce qui est vérifié, et par quoi

règlegarde-fouoù
tout opérateur Rust a son binding Pythontest_mirror_completeness.pycheck_python
tout verbe éligible a sa méthodetest_method_exposure.pycheck_python
les exemples et la formation tournentrun_examplescheck_examples
les doctests compilent et tournentcargo test --doccheck_rust
la rustdoc n’a aucun lien cassécargo doc avec RUSTDOCFLAGS="-D warnings"check_doc
les includes du book résolventdoc_lint.py includescheck_doc
aucune page ne possède de codedoc_lint.py fencescheck_doc
la prose ne cite pas de symbole disparudoc_lint.py symbolescheck_doc
tout item public a un exempledoc_lint.py doctestscheck_doc
toute entrée Python est citée par un exempledoc_lint.py api-pythoncheck_doc

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 :

  1. Les objets (Coords, SubMesh, NodeField, …) vivent derrière un Handle<T> — une référence comptée munie de son propre verrou. Le dernier handle qui disparaît emporte l’objet.
  2. 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é. Clone partage, Drop relâche. Quand le dernier handle disparaît, la valeur est détruite — son Drop s’exécute, donc les effets de bord (un SubMesh qui rend ses nœuds à la Coords) 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 / write ne peuvent pas échouer.
  • L’identité, c’est le pointeur. same_object ré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 :

  1. Des méthodes propres — h.read() plutôt qu’un trait d’extension importé partout.
  2. Une surface réduite — un Arc nu exposerait try_unwrap, get_mut, strong_count : de quoi contourner le verrou.
  3. 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 un Vec<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 :

  1. Maintenir dans la Coords une liste séparée des Node vivants, mise à jour à Clone / Drop. C’est le compteur actuel déguisé en HashSet<NodeId> — sans gain.
  2. Changer le contrat : Node devient 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 × incref sous un seul verrou — négligeable devant la création de la maille.
  • Node::clone / drop : un verrou et une indexation de Vec<u32>. Sensible seulement si on clone ou détruit beaucoup de Node en 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_cell pè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 parallel ré-exporte le prelude rayon et fixe la politique de grain (MIN_PARALLEL_LEN, via with_min_len) : les petits problèmes restent effectivement séquentiels, sans surcoût de threads.
  • Les drivers de models::kernel (element_pointwise, nodal_pointwise, assemble_block) sont les seuls porteurs de rayon côté physiques. Ils tiennent les guards de lecture, parallélisent par cellule et appellent un noyau pur fourni par la physique. Voir Ajouter une physique.

Ce qu’un noyau au point d’intégration n’a pas le droit de faire

Un noyau au point de Gauss tourne des dizaines de millions de fois dans une résolution non linéaire. Deux invariants l’encadrent :

  1. Aucun test que l’amont a déjà tranché — la présence d’une composante, la forme d’un champ, une Option à déballer. Les branchements qui restent sont ceux de la physique (l’essai élastique qui plastifie ou non).
  2. Aucune allocation dynamique — ni Vec, ni String, ni format!, ni structure intermédiaire construite par point.

La vérification n’est pas supprimée, elle est déplacée : un champ peut avoir été fabriqué à la main, on contrôle donc ses composantes et leur ordre une fois par zone, avant la région parallèle (Behavior::zone_layout pour la voie point, Domain::element_layout pour la voie matrice, kernel::element_pointwise), avec un message qui nomme le champ et l’écart. Le noyau reçoit alors la ligne de son point — une tranche empruntée du tampon — et une table d’indices : il indexe et calcule, rien d’autre. Un noyau conforme s’écrit sans un seul ? sur ses lectures, ce qui rend la règle vérifiable à l’œil.

Ce que cela vaut : sur la poutre console élasto-plastique 400×80, ~55 % du temps CPU partait dans la résolution de noms, le format!, le hachage et l’allocateur.

Zéro-copie

Les régions parallèles n’effectuent pas de copies intermédiaires. Plutôt que de recopier les données dans des Vec avant de calculer, on tient les read-guards pendant toute la région parallèle et on emprunte les tranches &[f64] en place (SubField::values(), SubMesh::connectivity(), dn_dx calculé à la volée). Les verrous de lecture sont concurrents : seuls les écrivains attendent. L’espace éléments finis n’a aucune mutabilité intérieure (méthodes &self pures), donc &SubFiniteElementSpace est Sync et appelable depuis plusieurs threads.

La règle ne s’arrête pas aux régions parallèles. Une .to_vec() sur une connectivité est suspecte partout, y compris dans du code séquentiel : elle coûte 4 octets par nœud et par copie, et elle se refait souvent à chaque assemblage ou à chaque évaluation de résidu. Deux idiomes reviennent, et aucun n’oblige à recopier :

  • Un opérateur de maillage lit toutes ses zones, puis écrit dans Coords (add_nodes, decref_all). Les deux phases sont séparées : la garde de lecture meurt avant l’écriture, il suffit de l’ouvrir pour la passe au lieu d’en extraire un tuple. Quand les deux se chevauchent vraiment, tenir la garde reste licite — SubMesh → Coords est l’ordre de verrouillage de tout le dépôt, jamais l’inverse, et SubMesh::to_poi1 le pratique déjà.
  • Un remplisseur de bloc ou de champ construit d’abord (un constructeur scelle, et sceller écrit), puis ouvre ses gardes et remplit. Les formes _with existent pour ça — SubMatrix::add_entry_with, SubNodeField::nodes_with : elles reçoivent le &SubMesh que l’appelant tient déjà, au lieu de reprendre un verrou à chaque entrée.

Les seules copies légitimes sont celles qu’on mute ensuite (dédoublonner, trier, étendre) et celles qu’impose une signature : une fonction qui rend une liste sans rendre la garde qui la porte, comme Cell::node_ids ou la frontière Python.

Déterminisme

Les opérateurs de champ et d’intégration parallélisés soit écrivent chaque case de sortie exactement une fois (écriture indexée / par_chunks_mut), soit sont une réduction associative sur les mêmes valeurs dans le même regroupement (min/max). Ces résultats sont bit-à-bit identiques au séquentiel, quel que soit RAYON_NUM_THREADS.

L’assemblage suit un schéma en deux temps. Le motif creux global (sparsité CSR) est construit depuis la seule topologie des blocs — indépendant des matériaux — puis mémoïsé sur le Model, donc réutilisé tel quel d’un assemblage à l’autre (le gain majeur pour une boucle de Newton, où seuls les matériaux changent). L’état assemblé partage ses tableaux d’indices avec ce motif : d’un assemblage au suivant, seules les valeurs sont neuves.

La phase numérique calcule et disperse dans la même passe, cellule par cellule, par coloration : deux cellules d’une même couleur ne partagent aucun DOF, donc les cellules d’une couleur écrivent en parallèle dans des cases disjointes — via un Vec<AtomicU64> (Relaxed, soit un simple mov sur x86, sans unsafe) — les couleurs étant traitées en séquence. Chaque tâche garde une matrice élémentaire de travail, réutilisée d’une cellule à l’autre : aucun ensemble élémentaire n’est matérialisé, là où les matérialiser toutes coûtait, sur un solide, des dizaines de gigaoctets écrits puis relus une fois. La coloration étant fixe, le résultat est déterministe (indépendant du nombre de threads), mais pas bit-à-bit face à une réduction séquentielle : la somme de chaque case est réordonnée par couleur. Un chemin de scatter séquentiel (en ordre de bloc), lui bit-à-bit, est conservé comme référence de test — c’est le seul qui calcule encore toutes les matrices élémentaires d’abord.

Le motif lui-même ne matérialise pas les paires (ligne, colonne) de chaque entrée : elles se déduisent de la position de chaque nœud dans les supports ligne et colonne, soit une poignée d’entiers par cellule au lieu d’une paire par entrée. Les cases CSR, elles, sont résolues une fois et gardées à plat ; quand l’ordre des DDL rend consécutives les colonnes d’un même nœud — ce que NodesThenVars vise, et que l’assembleur vérifie plutôt que de le supposer — une case de base suffit pour toutes les variables primales de ce nœud.

Le noyau élémentaire reçoit un CellGeom par espace EF du bloc : un seul pour une physique de continuum, plusieurs — partageant un maillage, ne différant que par la quadrature — pour un élément multi-quadrature (poutre de Timoshenko flexion/cisaillement, coque à venir). La sparsité ne dépendant que de la connectivité, ces éléments empruntent le même chemin de scatter parallèle sans machinerie supplémentaire : seul le noyau numérique lit plusieurs géométries.

Les scatters nodaux — les opérateurs Bᵀ (ops::node_field::divergence et les forces internes ops::node_field::internal_forces, Cast3m BSIG) et la charge répartie la charge répartie (∫ φ N) — dispersent tous de la même façon : chaque cellule calcule sa contribution locale, puis l’accumule dans ses nœuds. Ils passent tous par le même driver kernel::scatter_to_nodes (« intègre un noyau élémentaire et disperse aux nœuds »), qui s’appuie sur le helper parallel::colored_scatter : scatter par coloration (couleur = nœuds disjoints, Vec<AtomicU64>), le tampon local tenant par thread — aucun ensemble élémentaire n’est matérialisé, et calcul et scatter se font dans la même passe parallèle — comme l’assemblage matriciel, qui suit le même schéma. Le driver est agnostique à l’intégrande : l’appelant capture le sien (champ de contrainte, densité de flux…) dans la closure élémentaire. internal_forces est une divergence (de la contrainte), et ops::node_field::divergence en est le cas scalaire (n_dual = 1) ; flux en est l’instance « masse » pondérée par N plutôt que par ∇N. Déterministe par couleur, non bit-à-bit face à une somme en ordre de cellule.

Vérification : la suite de tests passe sous RAYON_NUM_THREADS=1 puis =8 ; les opérateurs write-once / réduction sont asservis à des valeurs exactes, l’assemblage parallèle à l’assemblage littéral à tolérance, plus un test de déterminisme.

Non bit-à-bit, par construction : l’assemblage parallèle et les scatters nodaux (divergence, forces internes, flux) — tous déterministes par coloration — et le solveur (back-end faer, pivotage/ordering différents de l’ancien LU dense). Tous dans les tolérances numériques.

Ce qui reste séquentiel (et pourquoi)

  • Fusion de champs — node_field.consolidate / element_field.consolidate (dédup et vérification de cohérence entre zones).
  • Mailleurs — les noyaux séquentiels par nature (Bowyer–Watson, front avançant) restent séquentiels.

Solveur

Le solveur utilise un LU creux multithreadé (faer) et met en cache la factorisation réutilisable dans la Matrix (factor once, solve many). Détails dans Opérateurs de solveur. Rôles des bibliothèques :

nalgebra            nalgebra-sparse                faer
primitives     →    stockage CSR/CSC + serde    →  factorise & résout (parallèle, creux)
(B, J, géométrie)   Matrice (scatter → CSR)        back-end solveur uniquement

Côté Python

Le parallélisme tourne à l’intérieur de chaque appel pyrucast (les threads rayon ne dépendent pas du GIL). Le GIL n’est pas relâché : deux appels pyrucast concurrents depuis des threads Python se sérialisent, mais un seul appel lourd profite pleinement de tous les cœurs.

Compilation et tests

Ce chapitre couvre l’installation complète pour développer sur pyrucast : tests Rust et Python, doctests, features Cargo, génération du stub .pyi, documentation. Pour le simple usage en Python (quatre commandes), voir Installation et démarrage rapide.

Prérequis

OutilVersionRôle
Rust (via rustup)≥ 1.89 (rust-version du crate), édition 2024Compilation du cœur
Python≥ 3.11 (3.13 testé)API Python et maturin
ruffrécentFormat du Python (ruff format), vérifié par check_format
mdbook≥ 0.4Génération de cette documentation
mdbook-mermaid≥ 0.14Rendu des graphes (ex. graphe des dépendances)

cargo fmt et clippy viennent avec la toolchain (rustup component add rustfmt clippy si besoin).

Système (uniquement pour builds avec l’API Python, voir features plus bas) :

  • Linux : installer les en-têtes Python — python3-dev (Debian/Ubuntu) ou python3-devel (Fedora/RHEL). pyo3 en a besoin pour l’édition de liens.
  • Windows : l’installateur officiel de Python inclut déjà les en-têtes.

Build Rust pur. Par défaut (cargo build / cargo test, sans feature), le crate ne compile ni pyo3 ni libpython : ni Python ni python3-dev ne sont requis. Voir « Usage en Rust pur » ci-dessous.

Mise en place du venv et des outils Python

Le venv (.venv à la racine) héberge maturin, pytest et ruff, et sert d’environnement cible à maturin develop.

Linux / macOS (bash)

python3 -m venv .venv
source .venv/bin/activate
pip install --upgrade pip maturin pytest ruff

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, ou cargo avec --features python-api/stub-gen), pyo3 cherche l’interpréteur via VIRTUAL_ENV : activez toujours le venv avant d’invoquer ces commandes. Un cargo build/cargo test pur (sans feature) n’a pas cette contrainte — il ne touche pas à Python.

Compilation et tests, étape par étape

Les commandes suivantes supposent le venv activé. Elles sont identiques sous Windows et Linux.

cargo fmt                  # format Rust — à passer AVANT toute vérification
ruff format .              # format Python — idem
cargo build                # cœur Rust pur (rlib + cdylib), sans pyo3
cargo test                 # tests unitaires + intégration + doctests (Rust pur)
cargo test --doc           # doctests explicitement
maturin develop            # construit et installe le module Python dans le venv
python -m pytest           # tests Python
cargo doc --no-deps --lib  # référence API Rust (rustdoc) — voir ci-dessous
mdbook build book          # génère cette documentation HTML

Le projet étant un mixed layout, maturin develop fait deux choses : il dépose l’extension compilée dans python/pyrucast/ (à côté des .py), et installe le paquet en mode editable dans le venv — un simple site-packages/pyrucast.pth pointant vers python/, pas une copie. Toute modification du Rust est donc intégrée au prochain maturin develop, et une modification des seuls .py est visible immédiatement.

Documentation Rust (rustdoc)

cargo doc --no-deps --lib génère la référence API Rust à partir des commentaires ///. Le résultat est dans target/doc/pyrucast/index.html.

  • --no-deps exclut la doc des dépendances (pyo3, serde, bincode, nalgebra) — gain de temps majeur.
  • --lib limite à la crate pyrucast (sans les tests d’intégration).
  • --open ouvre la page dans le navigateur à la fin.

Rustdoc et mdbook sont complémentaires : rustdoc couvre la référence par item ; ce mdbook couvre les principes, l’architecture et les exemples transverses.

La référence rustdoc est publiée à côté du book sur GitHub Pages, à https://pyrucast.github.io/pyrucast/rust/ (lien également présent en tête de l’introduction). Sa génération et son déploiement sont décrits dans « Publication automatique du book » ci-dessous.

Features Cargo

Plusieurs morceaux sont gardés derrière des features Cargo : ce qu’on n’active pas n’est ni compilé, ni embarqué. Toutes optionnelles, elles s’activent en cumul (--features a,b).

Sans aucune feature, le crate est du Rust pur : pyo3 n’est même pas compilé (dépendance optionnelle), donc aucun lien à libpython.

FeatureApportImpliqueQuand l’activer
python-apitire la dépendance pyo3 + compile le code des #[pyclass]/#[pyfunction] (toute l’API Python)dep:pyo3rarement à la main ; activée automatiquement par extension-module et stub-gen. La désactiver (défaut) donne un crate Rust pur.
extension-modulepython-api + dit à pyo3 de ne pas se lier à libpython (l’interpréteur hôte la fournit au chargement du .so)python-api, pyo3/extension-modulesystématique pour maturin develop / maturin build.
vizexport PNG/SVG (rendu CPU via plotters)—scripts headless, captures pour la doc, CI.
viz-interactivefenêtre interactive winit/softbuffer (souris, gizmo)vizenvironnement graphique disponible.
stub-genpython-api + binaire stub_gen qui produit le stub .pyipython-api, pyo3-stub-genaprès modification des bindings, pour rafraîchir le stub vu par les IDE.

extension-module vs stub-gen. extension-module produit un .so chargé par Python : pyo3 se passe alors du link à libpython. À l’inverse, stub-gen produit un binaire exécutable : il faut libpython linkée normalement, donc on n’active pas extension-module ce jour-là. Les deux ne sont jamais activées ensemble.

Usage en Rust pur

pyrucast s’utilise comme bibliothèque Rust sans rien de Python. Comme pyo3 est optionnel, une dépendance par défaut ne compile ni le binding ni libpython :

# Cargo.toml d'un projet Rust tiers
[dependencies]
pyrucast = { path = "…", default-features = false }   # Rust pur, pas de pyo3
#[test]
fn mailler_une_surface_depuis_un_contour() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let coins: Vec<Node> = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]]
        .iter()
        .map(|p| Node::create_in(coords.clone(), p).unwrap())
        .collect();
    let mut sm = SubMesh::new(coords, ElementType::SEG2);
    for i in 0..4 {
        sm.add_cell(&[coins[i].id(), coins[(i + 1) % 4].id()])?;
    }
    let contour = Mesh::from_submesh(sm);

    let mesh = mesh::triangulate_surface(&contour, ElementType::TRI3, Some(1.0))?;

    assert!(mesh.cell_count() > 0);
    Ok(())
}

Tout le cœur (containers, ops, interrupt, handle, …) est disponible et PyO3-free. Seule la couche py (les #[pyclass]) demande python-api. Pour interrompre un calcul long depuis du Rust pur, voir Interrompre une fonction.

Génération du stub Python (.pyi)

Les IDE (Pylance/Pyright, PyCharm) ne savent pas inspecter un .so compilé : sans stub, les complétions tombent sur Any. Le binaire stub_gen lit les annotations /// + les macros #[gen_stub_*] et écrit un stub complet :

# Activer le venv comme d'habitude (le binaire link à libpython).
source .venv/bin/activate
cargo run --bin stub_gen --features stub-gen

Le fichier produit est python/pyrucast/_pyrucast/__init__.pyi — le stub de l’extension compilée, à côté du paquet Python (mixed layout). Son chemin n’est pas choisi par le binaire : pyo3-stub-gen le dérive de python-source + module-name dans pyproject.toml.

Régénérer le stub à chaque changement de signature Python (nouvelle classe, paramètre, docstring ///). Le fichier est versionné dans le repo, utilisable tel quel par les IDE.

Les dunders polymorphes des agrégats

Une exception : __getitem__, __or__ et __ror__ des agrégats (Mesh, Model, NodeField, …) prennent et rendent un PyAny, dont le générateur ne peut déduire que typing.Any — et mesh[0]. ne proposerait alors plus rien dans l’IDE. Ces méthodes vivent donc dans des blocs #[pymethods] volontairement non décorés par gen_stub_pymethods, invisibles au générateur, et leurs entrées du .pyi sont écrites à la main en syntaxe Python dans les deux littéraux passés à impl_aggregate_pymethods! (voir src/py/mesh.rs) : surcharges @overload typées et docstrings propres à chaque agrégat, ce qui permet aussi de dire au bon endroit ce que | fait vraiment sur ce type-là. Le nom de classe y désigne le type Rust (class PyMesh:), et les types renvoyés passent par le marqueur pyo3_stub_gen.RustType[...].

Ces blocs non décorés sont fermés : une méthode qu’on y ajouterait disparaîtrait silencieusement du stub. Toute nouvelle méthode d’agrégat va dans les blocs décorés ; une méthode polymorphe de plus demande d’étendre le littéral correspondant, sur les sept sites d’appel de la macro.

Le même patron — bloc fermé + submit! adjacent — sert partout où une méthode écrite à la main est polymorphe : SubNodeField.__getitem__, SubElementField.__getitem__, Node.__or__/__ror__, Matrix.__mul__, et les sommes __add__/__sub__ de Matrix et SubMatrix (plus le __neg__ du bloc, logé dans le même bloc fermé). Il sert aussi à corriger une différence de vocabulaire : pyo3 regroupe les comparaisons sous __richcmp__, qui n’existe pas côté Python — le stub des champs déclare donc à la main __ge__/__gt__/__le__/__lt__, qui rendent un masque 0/1 et non un booléen.

Scripts « tout-en-un »

Le dossier script/ contient deux niveaux d’automatisation. Tous activent (ou créent) le venv automatiquement et s’arrêtent à la première erreur.

Autour d’eux gravitent quelques utilitaires : dev.sh / dev.ps1 (build minimal — module Python en release avec la visu interactive + stub, rien d’autre), run_examples.sh / run_examples.ps1 (exemples et 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.

commandece qu’elle testeoù vivent les teststempsquickfmtrustpyexdocallversion
cargo fmt --checkle Rust est formaté—1 s✓✓✓✓
ruff format --check .le Python est formaté—1 s✓✓✓✓
cargo check --all-targetsle build Rust pur compile encore—<1 s✓✓✓✓
cargo test --features viz1036 tests + 893 doctests#[cfg(test)] dans 123 fichiers de src/, 42 fichiers tests/*.rs, commentaires /// et //!154 s✓✓✓✓
cargo build --features viz-interactivela fenêtre winit compileaucun test — 960 lignes que rien n’exerce sans écran14 s✓✓✓✓
maturin develop --features extension-module,vizl’extension Python s’installe—20 s✓✓✓
python -m pytestl’API Python, unité par unité73 fichiers tests/python/test_*.py, dont 13 test_doc_*.py qui sont aussi les sources des exemples du book32 s✓✓✓
script/run_examples.shdes chaînes de calcul de bout en bout30 examples/*.py, 3 examples/*.rs, 6 formation/*.py80 s✓✓✓
cargo doc --no-deps --lib (-D warnings)la rustdoc n’a aucun lien cassé—1 s✓✓✓
python script/doc_lint.pyles cinq garde-fous du book et des exemplesbook/src/**.md, la rustdoc, le module Python installé28 s✓✓✓
mdbook build bookle book se rendbook/src/**1 s✓✓✓
cargo clippy × 4 jeux de featuresaucun avertissement, -D warnings—105 s✓

Et les totaux, dans le même régime :

scriptcontenutemps
check_formatformatage seul2 s
check_rustcœur Rust169 s
check_pythonliaison et tests Python64 s
check_examplesexemples et formation80 s
check_docrustdoc, garde-fous, book50 s
check_clippyquatre jeux de features, -D warnings~2 min
check_gmshl’interface gmsh (exige pip install gmsh)5 s
check_medcouplingl’interface MED (exige pip install medcoupling)2 s
check_quickformatage + Rust — la boucle de commit~3 min
check_allles cinq blocs~6 min
set_new_versioncheck_all + check_clippy~8 min

check_gmsh ne fait pas partie de check_all, pour la même raison que check_clippy n’en fait pas partie : on ne l’impose pas à la boucle quotidienne. Un développeur qui ne touche pas à l’import gmsh n’a donc pas à installer gmsh — les tests concernés se sautent proprement. On le lance en touchant à cette interface, et le job verify de la CI le lance toujours. Sous Linux, la roue gmsh embarque un libgmsh lié à OpenGL : il faut aussi les paquets système libglu1-mesa et libopengl0.

check_medcoupling suit la même règle pour l’interface MED. medcoupling n’est publié que pour Linux x86_64 et Windows : sur macOS, ce bloc ne peut pas tourner.

Deux remarques que ces chiffres appellent.

Les doctests ne coûtent presque plus rien, alors qu’ils représentaient 44 % de la suite entière. Le crate est passé à l’edition 2024, qui les fusionne en un seul binaire au lieu d’en compiler un par exemple : 893 doctests sont passés de 232 s à 17 s. Ce qui domine aujourd’hui le bloc Rust, ce n’est plus l’exécution des tests (37 s) mais la compilation des 42 fichiers de tests d’intégration.

Les doctests tournent sous viz, jamais sous viz-interactive. La couche interactive tire 58 crates de plus — winit, Wayland, X11 — et chaque binaire de test les lie. Elle est donc compilée sans être testée, ce qui suffit : elle ne porte aucun test qu’un écran ne soit nécessaire pour exercer.

bash script/check_quick.sh   # la boucle de commit : formatage + Rust
bash script/check_doc.sh     # je viens de toucher à la doc
bash script/check_all.sh     # la passe complète, à brancher en CI
.\script\check_doc.ps1        # Windows
.\script\check_all.ps1

check_all enchaîne les cinq dans cet ordre, qui n’est pas indifférent : le formatage d’abord (il échoue en une seconde), le cœur Rust ensuite, puis Python — qui (ré)installe l’extension dont les exemples ont besoin —, les exemples, et la documentation en dernier, la plus lente. Un bloc lancé seul qui a besoin du module compilé le dit plutôt que d’échouer obscurément.

script/check.sh reste comme alias de check_all.sh. check_quick est le raccourci du cas courant : il ne remplace pas check_all, il le précède — les exemples, Python et la doc restent à passer avant de pousser.

set_new_version.sh appelle check_all plutôt que de recopier ses pas : la copie précédente avait divergé, et ne lançait ni les garde-fous de documentation ni les exemples au moment précis où l’on pose un tag.

Trois points valent d’être notés :

  • les deux vérifications de format viennent en tête — le script vérifie, il ne formate pas. Passer cargo fmt et ruff format . avant de le lancer, sinon il s’arrête au premier pas ;
  • run_examples rejoue les exemples et les scripts 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-fouce qu’il tient
includeschaque {{#include}} résout : fichier, ancre, texte non vide
fencesaucune page ne possède de code
symbolesla prose ne cite aucun symbole disparu
doctestscliquet : la couverture des doctests ne peut que monter

Le premier est le plus important, parce que le mécanisme porteur n’est gardé par rien d’autre : une ancre inexistante rend un bloc vide, avec un code de retour 0 et sans un mot.

mdbook test a été retiré de la chaîne : tous les blocs Rust du book sont rust,ignore — c’est ce que le mécanisme d’inclusion impose —, si bien qu’il ne compilait rien. Un pas vert qui ne regarde rien coûte plus cher que son absence.

La règle complète, et le choix entre doctest, test d’intégration et exemple, est page Documentation et tests.

script/build.sh / script/build.ps1 — build complet + documentation

Le script « tout faire de bout en bout », Linux/macOS (build.sh, bash) et Windows (build.ps1, PowerShell) :

bash script/build.sh                         # Linux / macOS
.\script\build.ps1                           # Windows
# si les scripts sont bloqués :
powershell -ExecutionPolicy Bypass -File .\script\build.ps1

Il déroule, dans l’ordre :

  1. Vérification des prérequis — cargo, python (≥ 3.11), création/activation du venv, installation de maturin et pytest, installation de mdbook si absent (cargo install mdbook).
  2. Compilation + tests — cargo build, cargo test (unitaires + intégration + doctests), cargo test --doc, cargo test --features viz.
  3. Module Python avec visu interactive — maturin develop --release --features extension-module,viz-interactive, puis pytest.
  4. Documentation — cargo doc (référence Rust), régénération du stub python/pyrucast/_pyrucast/__init__.pyi, mdbook build, et un export pydoc HTML de l’API Python (target/python-doc/pyrucast.html).
  5. Vérification finale — le module s’importe et la visualisation est bien compilée (Mesh.plot présent) ; sinon le script échoue.
  6. Résumé — emplacements des trois documentations (book, rustdoc, pydoc) et les commandes pour ouvrir le livre et lancer une fenêtre interactive.

À la fin, la librairie Python est installée dans le venv avec la visualisation interactive : il suffit d’activer le venv et d’appeler mesh.plot() (save=None ouvre la fenêtre — cf. Visualisation).

Publication automatique du book

Le book est publié sur GitHub Pages à l’adresse https://pyrucast.github.io/pyrucast/, et la référence rustdoc à côté, sous https://pyrucast.github.io/pyrucast/rust/.

La publication est automatisée par un workflow GitHub Actions (.github/workflows/pages.yml) : à chaque push sur master qui touche au book ou aux sources Rust, le workflow build le book (mdbook build book) et la doc Rust (cargo doc --no-deps --lib), assemble un site combiné — le book à la racine, la rustdoc sous rust/ — et le déploie directement sur GitHub Pages via actions/deploy-pages (pas de branche pages, pas de token à gérer : le déploiement utilise les permissions natives du workflow). Il peut aussi être déclenché à la main (workflow_dispatch) depuis l’onglet Actions.

Prérequis à configurer une fois côté GitHub (interface web) :

  1. Settings → Pages → Build and deployment → Source : sélectionner GitHub Actions.

Les runners GitHub sont hébergés et gratuits sur dépôt public — pas de runner à enregistrer soi-même, contrairement à Codeberg.

Journal des versions et publication

Poser un tag vX.Y.Z déclenche .github/workflows/release.yml, qui publie le crate sur crates.io, les wheels et la sdist sur PyPI, puis crée la Release GitHub dont les notes sont écrites automatiquement à partir des messages de commit.

La convention de message de commit

C’est la seule obligation que cette automatisation ajoute, et le projet la suit déjà partout. Un sujet s’écrit type(portée): sujet, avec un ! avant le deux-points quand le changement casse l’API :

PréfixeSection du journal
type(portée)!: — n’importe quel type suivi de !Ruptures d’API, en tête
featNouveautés
fixCorrections
perfPerformances
refactorRemaniements
docsDocumentation
testTests
build, ci, choreCompilation et outillage
styleStyle
tout le resteDivers

Le sujet est repris tel quel dans les notes : il est écrit une fois, au moment du commit, et jamais retouché ensuite. Le corps du message, lui, n’est jamais publié — il reste pour qui lit git log. Deux cas particuliers sont traités par la configuration : chore: version X.Y.Z est sauté, et les commits docs(rustdoc) sont repliés en une seule ligne comptée, sans quoi ils noieraient tout le reste.

git-cliff n’est pas un prérequis

Le tri est fait par git-cliff, piloté par cliff.toml à la racine. Rien à installer : ni pour développer, ni pour publier. L’outil est téléchargé par le workflow, dans une version épinglée, comme mdBook l’est pour le book. Le tableau des prérequis en tête de page ne gagne donc aucune ligne.

Pour relire les notes avant de poser le tag, si on le souhaite :

cargo install git-cliff
git-cliff --unreleased --tag vX.Y.Z   # ce que la prochaine Release dira

Et si les notes publiées déplaisent, la Release s’édite dans l’interface GitHub : ni workflow à rejouer, ni tag à déplacer.

Une seule déclaration de version

Cargo.toml porte la version, et lui seul. pyproject.toml la lit de là (dynamic = ["version"]), et pyrucast.__version__ en vient déjà (src/lib.rs, env!("CARGO_PKG_VERSION")). Le crate, la wheel et le module importé descendent donc du même champ : ils ne peuvent pas diverger.

Le workflow refuse de démarrer si pyproject.toml redéclarait une version — une version statique l’emporterait sur Cargo.toml chez maturin et partirait seule sur PyPI.

De là l’invariant qui rend la page releases/ lisible :

La Release GitHub existe si et seulement si crates.io et PyPI portent réellement cette version, et si les artefacts joints portent les étiquettes de compatibilité attendues.

Le dernier job constate au lieu de supposer : il interroge les deux registres jusqu’à les y trouver, et vérifie sur les noms de fichiers qu’il y a bien trois wheels cp311-abi3 et une sdist. cargo publish comme l’action PyPI savent sauter une version déjà présente ; ce silence est utile pour rejouer un job, et dangereux partout ailleurs.

Le tag est le seul point de contrôle

Les push sur master ne sont pas vérifiés — assumé pour l’instant. La vérification se fait là où elle est irréversible : au tag. Le job verify précède tout le reste et lance exactement ce que set_new_version.sh lance en local, les cinq blocs de check_all puis check_clippy.

Un bloc par étape nommée, et non check_all.sh en une fois : l’interface Actions montre alors laquelle rougit sans qu’il faille dérouler tout le journal. Ce sont les mêmes scripts, appelés un à un plutôt que par leur boucle — et une étape vérifie que la liste des cinq blocs de la CI est encore celle de check_all.sh, faute de quoi un sixième bloc ne serait jamais lancé au moment de publier.

Un seul job, parce que l’ordre contraint : doc_lint.py importe pyrucast, donc check_doc exige que check_python ait construit le module.

Le job reconstitue l’environnement de développement — toolchain Rust, en-têtes fontconfig et freetype, mdBook et son préprocesseur mermaid, un venv avec maturin, pytest et ruff. Compter 20 à 30 minutes à froid, 10 à 15 ensuite grâce au cache Cargo.

set_new_version.sh garde malgré tout ses huit minutes : c’est l’échec rapide, avant que le tag existe. Découvrir la panne après coup obligerait à déplacer un tag, ou à brûler un numéro.

pyrucast-macros se publie à la main, et avant

Le workspace porte deux paquets, et release.yml n’en publie qu’un : le paquet racine. pyrucast dépend pourtant de pyrucast-macros en path et en version, donc cargo package comme cargo publish cherchent ce numéro dans l’index de crates.io. Tant qu’il n’y est pas, les deux échouent sur no matching package named pyrucast-macros found — y compris set_new_version.sh, à son étape cargo package, avant même de poser le tag.

L’outillage de macros bouge bien plus lentement que la bibliothèque, et sa version est délibérément décorrélée de la sienne : le publier à chaque tag n’aurait aucun sens. Il se publie donc à la main, à chaque fois qu’il change :

bash script/publish_macros.sh          # publie la version courante
bash script/publish_macros.sh 0.1.1    # la passe à 0.1.1, puis publie

Le script vérifie la crate seule (format, clippy -D warnings, tests, rustdoc) puis son consommateur — une macro procédurale ne prouve rien tant qu’un appelant ne l’a pas expansée —, empaquette, fait un --dry-run, et ne publie qu’après confirmation. Avec un numéro, il le reporte des deux côtés : dans macros/Cargo.toml et dans la dépendance de Cargo.toml, qui ne peuvent pas diverger sans que la publication cesse de débloquer quoi que ce soit. Il attend enfin que l’index serve la version, faute de quoi le cargo package suivant échouerait exactement comme avant.

On construit tout avant de publier

L’ordre des jobs compte autant que l’attestation finale. On construit tout avant de publier quoi que ce soit : crates-io attend la sdist et les wheels, parce que cargo publish est une porte à sens unique et qu’un numéro ne se republie jamais. La 0.3.2 a servi de démonstration — elle est partie sur crates.io pendant qu’une cible de la matrice échouait, et PyPI ne l’a jamais reçue.

Ce que porte chaque distribution

pip install pyrucast prend une wheel quand il en existe une pour la plateforme, et retombe sinon sur la sdist, qu’il compile. Les deux ne portent pas la même chose :

PlateformesFeatures compiléesPrérequis
wheel cp311-abi3Linux x86_64 (manylinux2014), Windows x86_64, macOS universal2extension-module, viz, viz-interactiveaucun — Python ≥ 3.11
sdist .tar.gztout le reste (ARM, musl, BSD…)extension-module seulrustup, et la compilation du crate

Il n’y a pas de wheel Linux aarch64 : viz lie fontconfig et freetype, et pkg-config refuse par principe de fonctionner en cross-compilation. La construire supposerait d’émuler un conteneur aarch64 — trois minutes de compilation x86_64 en deviennent quinze à trente. À reprendre séparément, sans version en jeu.

Une installation depuis la sdist n’a donc pas la visualisation : mesh.plot() n’existe pas. C’est assumé — compiler viz exigerait fontconfig et freetype sur la machine cible — mais ce n’est pas silencieux :

features = pyrucast.__features__
assert isinstance(features, tuple)
assert features, "an imported module compiles at least extension-module"
assert all(isinstance(f, str) for f in features)
assert "extension-module" in features

__features__ liste ce que ce binaire porte : ('python-api', 'extension-module', 'viz', 'viz-interactive', 'abi3') sur une wheel publiée, ('python-api', 'extension-module') depuis la sdist. Le test qui précède ne se contente pas de lire la liste — un second vérifie qu’elle dit vrai plutôt que d’être recopiée à la main : la présence de viz doit équivaloir à celle de Mesh.plot.

Pré-versions. Un tag v0.4.0-rc1 reste possible, mais crates.io garderait 0.4.0-rc1 là où PyPI normalise en 0.4.0rc1 : le workflow rapproche les deux formes et refuse celles qu’il ne sait pas superposer. Il faudrait aussi élargir la regex de test_version_exposed, qui n’accepte aujourd’hui que X.Y.Z.

Dépannage rapide

  • error: failed to run the Python interpreter at ... lors d’un cargo build : le venv n’est pas activé, ou un VIRTUAL_ENV obsolète pointe vers un chemin invalide. Réactivez le venv du projet.
  • No module named 'pyrucast' lors de pytest : maturin develop n’a pas déposé le module. Vérifier que VIRTUAL_ENV est défini, puis relancer maturin develop.
  • mdbook: command not found : installer mdbook via cargo install mdbook ou un binaire publié.
  • The "mermaid" preprocessor exited unsuccessfully (ou graphes affichés en bloc de code brut) : installer le préprocesseur via cargo install mdbook-mermaid. Il doit être sur le PATH au moment de mdbook 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 :

  1. src/models/<ma_physique>.rs (nouveau) — une struct portant ses supports, un impl SubModelKind, un constructeur new(...), ses tests, et une invocation de physics_operator! qui déclare l’opérateur public avec sa documentation (calque sur truss.rs, le cas le plus court).
  2. src/containers/model.rs — une variante dans enum SubModel et une ligne dans SubModel::as_kind().

Plus deux lignes de raccordement : pub mod dans src/models/mod.rs, pub use dans src/ops/model/mod.rs, et l’enregistrement dans le #[pymodule] de src/lib.rs (le module _pyrucast est plat).

Ce qu’on n’écrit plus. Le balayage des sous-espaces, le #[pyfunction], son attribut de stub, le déballage des enveloppes Python : physics_operator! les émet. Un auteur de physique n’ouvre jamais src/py/.

#![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 en symmetry=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 :

traitce que le sous-modèle fait avant d’appeler la loisignatureénuméré
StatelessLawKindrien : la loi ne voit ni état, ni dtstress(ε, matériau)ElasticLaw
ReturnMapLawKindle prédicteur élastiquereturn_map(σ_essai, prev, matériau, dt)PlasticLaw
DirectUpdateLawKindrien : la loi reçoit ε et l’état de Aupdate(ε, prev, matériau)DamageLaw

C’est bien la structure qui décide, pas le nom : ViscoplasticLemaitreChaboche est physiquement « viscoplasticité + endommagement » et siège du côté plastique, parce qu’elle est de forme retour radial. Une loi hyperélastique rejoindrait StatelessLawKind ; une loi viscoélastique, DirectUpdateLawKind.

Tout le reste est générique et ne change pas.

Le trait SubModelKind

Défini dans src/models/mod.rs. Le trait de base ne porte que le dénominateur commun de tout sous-modèle ; chaque capacité optionnelle est un sous-trait séparé, exposé par un seam as_*() qui rend None par défaut. Ces sous-traits nomment chacun un fait, et un seul : Domain — j’intègre sur un espace EF, avec du matériau —, Behavior — et j’ai une loi évaluée en chaque point —, Constraint — mes relations existent sous forme neutre, on peut m’imposer autrement que par mes blocs. Behavior a Domain pour supertrait : toute loi s’intègre, toute intégration n’a pas de loi. C’est précisément ce qui manquait — un transfert de bord intègre ∫ h NᵀN et n’a aucune loi ; tant que les deux faits étaient un seul, il devait s’en inventer une. Une struct n’implémente que la capacité qui la concerne : elle n’a donc jamais de méthode « présente mais qui erronerait ». Un domaine typique implémente primal_vars, dual_vars, physics, as_domain + Domain, le noyau element_matrix, stiffness_layout, label et render. Il n’écrit pas build_stiffness_blocks : le défaut le dérive de stiffness_layout + element_matrix.

Seules trois méthodes sont sans défaut : primal_vars, dual_vars et physics (plus label / render pour l’affichage). Tout le reste se redéfinit à la carte.

pub trait SubModelKind: Sync {
    fn primal_vars(&self) -> Vec<String>;
    fn dual_vars(&self) -> Vec<String>;
    // Nature(s) de la physique — slice constante, pendant de `label` ; sert
    // aux sélecteurs `Model::filter` / `Matrix::filter` (match par appartenance) :
    fn physics(&self) -> &'static [Physics];
    // Seams de capacité — None (défaut) ⇒ la struct n'a pas cette capacité.
    // Une struct qui l'a redéfinit le seam pour rendre `Some(self)` :
    fn as_domain(&self)     -> Option<&dyn Domain>     { None }
    fn as_constraint(&self) -> Option<&dyn Constraint> { None }

    // Le pont vers les noyaux de matrice, qui vivent sur `Domain` (voir plus
    // bas) — fourni, on ne l'écrit pas :
    fn matrix_element(&self, kind: MatrixKind, /* … */) -> Result<()> { /* as_domain() puis route */ }

    // ── Déclarations structurelles du bloc *calculé*, une par MatrixKind ;
    // None (défaut) ⇒ pas de terme de ce genre pour cette physique :
    fn stiffness_layout(&self)  -> Option<MatrixLayout> { None }
    fn mass_layout(&self)       -> Option<MatrixLayout> { None }
    fn geometric_layout(&self)  -> Option<MatrixLayout> { None }
    fn tangent_layout(&self)    -> Option<MatrixLayout> { None }
    fn matrix_layout(&self, kind: MatrixKind) -> Option<MatrixLayout> { /* fourni */ }

    // 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éclarerésout (1× par zone)consomme
voie point de Gauss (Behavior)deformation_reads / state_readszone_layout → ZoneLayoutintegrate_point
voie matrice (Domain)material_components / element_state_readselement_layout → ElementLayoutelement_matrix & consorts

Conséquences pratiques — pour donner une capacité à une physique, implémenter le sous-trait et redéfinir le seam correspondant pour rendre Some(self) :

  • Domaine (physique sur une région) : impl Domain + as_domain(). Déclarer material_fespace() (+ material_components()) — l’assembleur (src/ops/matrix.rs) sélectionne et valide le SubElementField automatiquement — et les noyaux de matrice qu’on a. Tous sont fournis et erronent par défaut : une physique peut intégrer sans produire de matrice d’un genre donné, voire aucune.

  • Comportement (une loi au point) : impl Behavior + as_behavior(). Déclarer behavior_fespace() + behavior_output_components() + deformation_reads() + integrate_point(...), la loi de constitution en un point de Gauss. integrate_behavior est fourni : il résout la zone, puis pilote ce noyau en parallèle sur toutes les cellules. Un élément linéaire en a bien une, simplement triviale (N = E·A·ε) ; un transfert de bord, lui, n’en a pas — son h·a est le coefficient de son propre opérateur appliqué en un point, et son résidu suit de l’opérateur.

    Deux invariants tiennent dans ce noyau, et ils décident de la forme du reste : aucun test que l’amont a déjà tranché — présence d’une composante, forme d’un champ, Option à déballer — et aucune allocation dynamique. Un noyau conforme s’écrit sans un seul ? sur ses lectures. Ce que l’auteur déclare (deformation_reads, state_reads, material_components) est exactement ce qui permet cela : la convention est traduite en positions une fois par zone, dans zone_layout, où un champ fabriqué à la main dans un autre ordre est refusé avec un message qui nomme le champ et l’écart. Les branchements qui restent sont ceux de la physique. La modélisation, elle, ne s’écrit pas ici. Une physique du continu en petites déformations tient un Continuum (src/models/continuum/) au lieu de redéclarer sa géométrie : il porte le sous-espace EF, le support POI1, la dimension et la cinématique, valide les trois cohérences à la construction (élément solide et non variété, cinématique possible dans cet espace, accord géométrie ↔ axisymétrie), et fournit les noyaux element_stiffness, element_mass, element_geometric et element_tangent_from_state ainsi que la nomenclature Voigt. Élasticité, plasticité et endommagement s’en servent toutes trois — c’est ce qui rend le produit modélisation × loi réel plutôt qu’une délégation d’une physique vers sa voisine.

  • Contrainte (multiplicateurs de Lagrange) : impl Constraint (multiplier_mesh() + relations()) + as_constraint(). SubModel::multiplier_nodes() et multiplier_mesh() en découlent. C’est le foyer de la famille contrainte — Dirichlet, Mpc, Embedded, Contact.

  • Terme de masse, raideur géométrique, tangente cohérente : déclarer le *_layout correspondant et écrire le noyau element_* (voir ci-dessous).

Une contrainte comme Dirichlet n’implémente que Constraint (plus contributions, cf. ci-dessous) : elle n’a ni element_matrix, ni Domain — leur absence est un fait de compilation, pas une erreur à l’exécution.

La nature : physics()

physics() rend une slice constante de Physics (Mechanical, Thermal, Constraint, Other) : la classification grossière de la physique, orthogonale à l’axe de capacité Domain/Constraint. Elle est requise — chaque physique déclare sa nature à son site de définition, une physique couplée en déclarant plusieurs. Cette information voyage avec chaque bloc assemblé jusqu’à la SubMatrix, et alimente les sélecteurs Model::filter / Matrix::filter, qui matchent par appartenance. Un HeatConduction rend &[Physics::Thermal], un Dirichlet &[Physics::Constraint].

Un genre de matrice = un layout + un noyau

L’assemblage est agnostique au genre de matrice. L’énum MatrixKind (Stiffness, Mass, Geometric, Tangent) est le discriminant qui fait tourner la même machinerie — recette, scatter colorié, cache de motif creux par genre — avec un noyau élémentaire différent :

MatrixKindCast3mintégralelayoutnoyau
StiffnessRIGI / COND∫ Bᵀ D Bstiffness_layoutelement_matrix
MassMASS / CAPA∫ ρ Nᵀ Nmass_layoutelement_mass
GeometricKSIG∫ Gᵀ σ̂ Ggeometric_layoutelement_geometric
TangentKTAN∫ Bᵀ D_alg Btangent_layoutelement_tangent

Ajouter un terme à une physique, c’est donc deux méthodes : le *_layout (souvent le même que celui de la raideur — mêmes espaces EF, même support, mêmes variables : seul le noyau diffère) et le element_*. Une physique sans terme d’un genre ne redéfinit rien : son layout reste None, elle ne contribue pas, et ops::matrix::mass(...) sur un modèle qui la contient l’ignore simplement. Côté opérateurs, un point d’entrée par genre — ops::matrix::{stiffness, mass, geometric, tangent}, plus lump — tous adossés au même assemble_kind.

La raideur géométrique reçoit en plus un state : la contrainte courante, produite par integrate_behavior. C’est le couple producteur/consommateur.

La tangente, elle, ne reçoit aucun champ d’état : D_alg est évalué au point de Gauss par tangent_point, à partir des mêmes entrées que integrate_point. Un champ de modules n’aurait eu que l’assembleur pour lecteur, et il pesait de 6 à 21 réels par point — jusqu’à plus de la moitié de la ligne de comportement en 3-D. Surtout, l’émettre depuis COMP faisait payer sa dérivation à chaque itération : pour les huit lois plastiques sans forme fermée, treize retours radiaux par point au lieu d’un. La tangente se demande donc, en appelant ops::matrix::tangent, et Behavior::tangent_source() dit d’avance ce qu’elle coûtera.

Les forces internes

build_internal_forces(stress) est fourni : il pilote internal_force_element en parallèle sur les espaces EF du stiffness_layout et disperse aux nœuds de son support. Le noyau élémentaire par défaut est celui de la mécanique des milieux continus — f_{i,a} = Σ_g Σ_b (∂N_i/∂x_b) σ_ab, lu en nommage Voigt (sigma_xx, sigma_xy, …), terme de cerceau compris en axisymétrie. Une physique dont le dual n’est pas un vecteur déplacement (thermique, barre, poutre, coque) redéfinit internal_force_element. Pour une loi linéaire, le résultat vaut K·u.

Redéfinir, c’est écrire le transposé du B que la physique intègre déjà dans sa rigidité — non en dériver un second. Les structurels rendent donc ce B explicite et le partagent entre les deux sens : models::beam::b_into pour les poutres, models::shell::b_into pour les coques. tests/internal_forces.rs mesure l’égalité f_int == K·u qui en découle, et elle est exacte : la loi d’un élément structurel est linéaire, il n’y a pas de tolérance physique à choisir.

Le noyau reçoit la géométrie et l’état, jamais le matériau — le B d’un continuum est le gradient symétrique, il ignore tout module. Une physique dont le B dépend, lui, du matériau doit donc lui faire porter ce dont il a besoin : Timoshenko ajoute Φ à son comportement, seule grandeur non conjuguée que ce dépôt garde en état, et elle se justifie parce que le résidu la relit à chaque itération de Newton.

Le parallélisme est gratuit (et invisible)

Les noyaux qu’une physique écrit — integrate_point (un point de Gauss), element_matrix & consorts (la matrice élémentaire d’une cellule), internal_force_element — sont séquentiels et purs : ils ne voient ni rayon, ni un handle, ni un verrou. Les drivers de models::kernel portent la parallélisation et le zéro-copie au-dessus d’eux. Voir Parallélisme.

Concrètement, une physique de continuum déclare son layout (espaces EF, support, variables, ordering) : le défaut de contributions() en tire une Contribution::Computed, et l’assembleur global bâtit un bloc calculé puis disperse le noyau élémentaire directement dans le CSR, en parallèle par coloration des cellules — sans matérialiser de COO. La voie littérale (build_stiffness_blocks) est le second défaut du trait, dérivée du même couple stiffness_layout + element_matrix via kernel::assemble_block ; elle sert de référence d’équivalence et de repli, mais une physique volumique ne l’écrit plus.

Une contrainte comme Dirichlet (aucun layout, rien d’intégré sur une cellule) redéfinit directement contributions() : elle rend Vec::new() pour tout genre autre que Stiffness, et ses blocs C / Cᵀ en Contribution::Literal pour celui-là — l’assembleur reste sans aucun cas particulier « Dirichlet ».

Un bloc inter-maillages : Coupling

Une physique d’interface (l’échange h(c₁ − c₂) entre deux corps qui ne partagent pas leurs nœuds) a besoin de blocs dont les lignes vivent sur un maillage et les colonnes sur un autre. C’est la troisième variante, Contribution::Coupling(CouplingLayout).

Tout ce qui est sous ce seam était déjà asymétrique lignes/colonnes : SubMatrix::computed prend deux supports, et le scatter comme les drivers de noyau les passent séparément. Le seul point qui les confondait était le champ unique MatrixLayout.support — d’où un layout séparé plutôt qu’un champ de plus, qui aurait touché les treize physiques existantes pour un besoin qu’aucune n’a :

///
/// ```
/// # use pyrucast::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 internes VAR(A), et pour les lois incrémentales la cinématique ε(A). Vaut None au premier pas (configuration de référence) ;
  • material — les données matériau de la zone, Some(_) ssi la physique déclare un material_fespace ;
  • dt — l’incrément de temps, None pour 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 sur contributions(kind, …) et pilote le matériau via le seam as_domain() (Domain). Aucun match par variante.
  • src/ops/element_field/behavior.rs et material_field.rs, avec leur wrapper src/py/ops/element_field.rs.
  • src/ops/node_field/internal_forces.rs : passe par build_internal_forces().
  • src/py/ops/matrix.rs : les assembleurs délèguent à model.inner.

Pour finir

Régénérer le stub python/pyrucast/_pyrucast/__init__.pyi (cargo run --bin stub_gen --features stub-gen, venv activé), puis builder + tester avant de commiter — script/check_all.sh pour la passe complète, ou le seul bloc concerné (check_rust.sh, check_doc.sh…).


Pourquoi ça passe l’échelle

Avec des dizaines de physiques, deux propriétés comptent : le coût d’ajout et la persistance.

Coût d’ajout : O(1) fichier

Le comportement d’une physique est co-localisé dans son fichier (struct

  • impl SubModelKind + les impl de capacité qui la concernent). Ajouter la physique n°30 ne touche que 4 endroits (§ Les étapes), dont 2 sont des lignes uniques dans model.rs. Aucune des méthodes génériques n’est modifiée. C’est l’inverse du « shotgun surgery » qu’imposerait un enum où chaque méthode ferait son propre match : là, ajouter une physique forcerait à éditer une dizaine de sites.

La même propriété tient sur l’autre axe : ajouter un genre de matrice a coûté un variant de MatrixKind, un *_layout et un element_* par physique concernée — l’assembleur, le cache de motif et le scatter n’ont pas bougé.

Les deux lignes (variante + bras de as_kind) pourraient même être générées par une macro physics_enum! { HeatConduction, Dirichlet, … } pour ne laisser qu’une seule déclaration.

Pourquoi garder l’enum (et pas Box<dyn SubModelKind>)

La persistance utilise bincode sur des Serialize/Deserialize dérivés (src/persist.rs), un format non auto-descriptif. Or :

  • un enum SubModel se sérialise nativement (indice de variante + payload), zéro code manuel ;
  • un Box<dyn SubModelKind> imposerait typetag, qui ne supporte pas les formats non auto-descriptifs comme bincode. On perdrait la persistance.

L’enum donne aussi l’exhaustivité : le compilateur refuse d’oublier un cas dans as_kind(). On obtient donc le meilleur des deux mondes — sérialisation triviale et exhaustivité de l’enum, comportement co-localisé et coût d’ajout constant du trait.

Et les données neutres partagées ?

Une donnée commune à toutes les physiques (un name, un flag enabled, une pondération) se traite selon sa nature :

  • dérivable (calculable à partir du type/de l’état) → un défaut dans le trait SubModelKind la fournit gratuitement à toutes les physiques, ex. fn weight(&self) -> f64 { 1.0 }. Le trait « l’impose et l’implémente automatiquement ». C’est exactement le statut de physics(), à ceci près qu’elle est volontairement sans défaut : la nature ne se devine pas, on veut que chaque physique la déclare.
  • stockée et mutable (saisie à l’exécution) → un trait ne peut pas porter de champ ni en générer un par défaut : il imposerait un accesseur fn meta(&self) -> &Meta, mais chaque struct devrait alors stocker le champ (le boilerplate par-physique que la fusion supprime). Le bon foyer redevient alors un wrapper struct SubModel { kind, meta } — à ré-introduire si et seulement si ce besoin apparaît (cf. la discussion dans le chapitre Modèle physique).

Bilan

  • Format de persistance : enum + bincode, stable.
  • Coût d’ajout : O(1) fichier, ~2 lignes de câblage.
  • Comportement : co-localisé par physique.
  • Le seul match par variante du module modèle est as_kind().

Ajouter un élément fini

Ce chapitre liste tous les points de code à toucher pour ajouter un type d’élément. Comme pour ajouter une physique, le coût est O(1) fichier : il ne dépend pas du nombre d’éléments déjà en place.

Le principe en une phrase

L’énum ElementType ne sert qu’au stockage et à la sérialisation ; tout le comportement vit dans une struct par élément (sous src/atoms/element_kind/) qui implémente le trait ElementKind. Un unique point de dispatch, ElementType::as_kind(), relie les deux. Le code générique — skin, orient, border, les localisateurs, l’export, le rendu — ne fait jamais de match par variante.

ElementType  (enum : stockage + sérialisation bincode + nom cast3m)
├── POI1, SEG2, TRI3, …, HEX27
├── ALL          -> &'static [ElementType]   ← la seule énumération
└── as_kind(&self) -> &'static dyn ElementKind   ← l'unique match

ElementKind  (trait : le comportement d'un type d'élément)
├── Identité (requis)
│   ├── element_type / ref_nodes / reversal_permutation / corner_count
│   └── nodes_per_cell, topological_dim, is_quadratic  (fournis, tirés de ref_nodes)
├── Topologie de référence (défaut : rien)
│   └── facets -> &[Facet] / edges -> &[[usize; 2]]
├── Domaine de référence (requis)
│   └── ref_centroid / ref_measure / contains_ref / clamp_ref
├── Interpolation (requis)
│   ├── degree -> Option<Interpolation>
│   ├── shape_into / dshape_into        (formes sans allocation)
│   └── shape / dshape                  (fournis : surcouche allouante)
├── Quadrature
│   ├── gauss -> (Vec, Vec)             (requis)
│   └── reduced                         (fourni : centroïde × mesure)
├── Échange (requis)
│   └── vtk_code / gmsh_code / gmsh_permutation / med_permutation
│                                         (permutations : identité par défaut)
└── Familles (défaut : None)
    └── quadratic / linear_parent / split_into

Les étapes

Ajouter un élément se réduit à trois gestes :

  1. src/atoms/element_kind/<mon_element>.rs (nouveau) — une struct unité et son impl ElementKind. Calquer sur tri3.rs (cas linéaire le plus court), tri6.rs (quadratique, qui délègue son domaine à son parent linéaire) ou pyra5.rs (le cas difficile : fonctions de forme rationnelles et quadrature conique).
  2. src/atoms/element_kind/mod.rs — un mod <mon_element>; et un bras dans as_kind().
  3. src/atoms/element_type.rs — une variante dans l’énum, son rustdoc, et une entrée dans ElementType::ALL.

Rien d’autre. En particulier :

  • aucun wrapper PyO3 : ElementType traverse la frontière en chaîne, et from_name se déduit de ALL + name() ;
  • nodes_per_cell, topological_dim et from_name sur ElementType délèguent au trait, et n’ont donc pas de bras à compléter ;
  • skin, orient, border, convert, to_quadratic, l’export VTK, la lecture gmsh, l’échange MED, le rendu et la subdivision colorée sont génériques et ne changent pas — à condition que gmsh_permutation et med_permutation disent comment ces formats numérotent les nœuds de la maille. Pour un volume, la numérotation MED se vérifie contre medcoupling : tests/python/test_medcoupling.py exige un volume positif et des nœuds milieux au milieu des arêtes qu’il en déduit ; ajouter le type à sa table REFERENCE.

Reste à écrire la doc : une fiche book/src/elements/<nom>.md, une entrée dans SUMMARY.md, une ligne dans 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ête k est donc toujours à l’indice local corner_count() + k. QUA9 et HEX27 ajoutent leurs centres de face et de volume après. C’est ce qui permet à to_quadratic, au fil de fer et aux facettes quadratiques de se déduire d’une seule table d’arêtes.

Les facettes

facets() rend les facettes orientées vers l’extérieur — les arêtes d’une surface, les faces d’un volume. Une facette est un élément à part entière : une face de TET10 est un TRI6, une face de HEX27 est un QUA9. Le champ nodes porte donc les nœuds milieux, et Facet::corners() restreint aux coins — ce sur quoi deux mailles voisines s’accordent quel que soit leur degré, donc ce qui sert de clé d’adjacence.

C’est cette seule table qui alimente skin, orient, le culling des faces cachées et la subdivision du rendu.

Le domaine de référence

contains_ref et clamp_ref décrivent le domaine et la projection dessus ; ref_centroid en donne un point intérieur, qui sert à la fois de départ au Newton d’inversion et de point de la quadrature réduite. ref_measure est la mesure du domaine, à laquelle les poids de quadrature doivent sommer.

L’interpolation

degree() déclare le degré Lagrange du type — un TRI6 est quadratique, ce n’est pas un choix. shape_into et dshape_into écrivent dans un tampon fourni : c’est la forme à implémenter, parce que l’inversion de la géométrie (locate_points, project_points) les appelle dans une boucle de Newton, où une allocation par appel dominerait le coût. shape/dshape sont les surcouches allouantes, fournies.

Astuce de validation : recouper les dérivées analytiques par différences finies, ce que fait déjà check_dshape_matches_fd pour tous les types.

Le Jacobien, son déterminant (y compris le cas manifold \( d_s > d_r \)) et les dérivées physiques \( \partial N_i / \partial x \) sont génériques — rien à écrire.

La quadrature

gauss() rend les points et poids de la règle par défaut, calibrée pour intégrer exactement la matrice de masse Lagrange-1 sur un élément droit. La règle réduite ne s’écrit pas : c’est le défaut du trait, un point au centroïde portant toute la mesure.

Les tests que vous obtenez gratuitement

src/atoms/element_kind/mod.rs porte une batterie d’invariants qui boucle sur ElementType::ALL — un type neuf y entre le jour où il est déclaré, sans une ligne de test à écrire :

  • métadonnées cohérentes entre l’énum et le trait ;
  • nœuds milieux exactement au milieu de leur arête ;
  • arêtes bien formées, tout coin sur au moins une arête ;
  • facettes de codimension 1, du même degré que leur parent, géométriquement cohérentes avec ref_nodes ;
  • jeux de coins de facettes distincts ;
  • centroïde intérieur, nœuds de référence dans le domaine, clamp ramenant dedans ;
  • codes VTK et gmsh uniques, permutations gmsh et MED bijectives ;
  • couple linéaire ↔ quadratique réciproque, à coins et arêtes égaux ;
  • somme des poids = mesure de référence ;
  • partition de l’unité, somme des dérivées nulle, Kronecker aux nœuds, dérivées analytiques contre différences finies.

À ajouter à la main : un test de volume sur un élément droit de géométrie connue (containers/finite_element_space/mod.rs en a un par type), et un aller-retour de sérialisation si des données nouvelles apparaissent.

Pourquoi garder l’énum

Même raison que pour les physiques : bincode n’est pas auto-descriptif, et typetag — nécessaire pour sérialiser un Box<dyn ElementKind> — ne le supporte pas. On perdrait la persistance. L’énum donne en prime l’exhaustivité au compilateur sur as_kind(). Le trait n’est jamais sérialisé : as_kind() rend un &'static, reconstruit à la volée depuis la variante.

Ce que ça a coûté, et rapporté

Avant ce découpage, le savoir d’un élément était réparti sur une vingtaine de fichiers et recopié jusqu’à quatre fois : les facettes en trois exemplaires, les nœuds de référence en quatre, le centroïde en trois. Huit de ces tables retombaient sur _ => None, donc s’oubliaient sans un mot du compilateur — et trois bugs en vivaient (skin muet sur PYRA5 et sur tout élément quadratique, un défaut de rendu silencieusement faux, une liste de test à taille figée).

Ce chapitre listait alors un tableau « quel fichier pour quoi ». Il n’en a plus besoin : il n’y a plus de liste de fichiers à parcourir, seulement un trait à remplir.

Interrompre une fonction

Certains opérateurs (mailleurs, solveurs, boucles de raffinement) peuvent tourner longtemps. On veut alors pouvoir les interrompre proprement — typiquement par un Ctrl+C depuis Python, mais aussi par un timeout ou un signal externe depuis un programme Rust. Ce chapitre explique le principe et comment l’implémenter pour un nouvel opérateur.

Pourquoi un Ctrl+C ne suffit pas

Quand une fonction Rust appelée depuis Python tourne dans une longue boucle, elle garde le GIL et ne rend jamais la main à l’interpréteur. Or KeyboardInterrupt n’est levée par Python qu’entre deux bytecodes. Le Ctrl+C (signal SIGINT) est donc bien enregistré, mais il ne se déclenchera qu’au retour de la fonction Rust : pendant le calcul, la combinaison paraît sans effet.

La solution est l’interruption coopérative : la boucle longue vérifie elle-même, à intervalles réguliers, s’il faut s’arrêter.

Le principe : un jeton, pas un détail de frontend

L’écueil serait de faire appeler Python::check_signals directement par l’opérateur — cela couplerait le cœur de calcul à PyO3 et casserait l’usage en Rust pur (cf. Compilation et tests, build sans python-api).

À la place, l’opérateur reçoit un jeton d’interruption abstrait — le trait Cancel du module interrupt — qu’il interroge périodiquement. C’est le frontend qui décide comment l’interruption est signalée :

FrontendJetonDéclencheur
Rust purNoCancel / ()jamais
Rust purAtomicBoolun autre thread / handler ctrlc lève le drapeau
Rust purDeadlinedépassement d’un délai (timeout)
PythonPySignals (dans src/py, gaté python-api)Ctrl+C via Python::check_signals

Le trait est du Rust pur, sans PyO3 :

///
/// ```
/// # use pyrucast::error::PyrucastError;
/// # use pyrucast::interrupt::{Cancel, Deadline};
/// # use std::sync::atomic::{AtomicBool, Ordering};
/// // The token decides **how** the stop is signalled; the operators
/// // n'en savent rien, et restent libres de toute considération de
/// // frontal — Ctrl+C, délai, bouton d'interface.
/// let stop = AtomicBool::new(false);
/// assert!(stop.check().is_ok());
/// stop.store(true, Ordering::Relaxed);
/// assert!(matches!(stop.check(), Err(PyrucastError::Interrupted)));
/// ```
pub trait Cancel {
    /// Poll the cancellation state. Called frequently, so keep it cheap.
    fn check(&self) -> Result<()>;
}

L’erreur renvoyée est PyrucastError::Interrupted. Sa conversion vers Python (gatée python-api) produit une vraie KeyboardInterrupt, pas un RuntimeError générique. Le cœur, lui, ne voit que &dyn Cancel : il reste PyO3-free et utilisable depuis un programme Rust.

Implémenter l’interruption dans un opérateur

Trois gestes.

1. Faire passer le jeton et le sonder. Le cœur de calcul prend un &dyn Cancel et l’interroge à chaque tour de boucle (un tour = un événement grossier — un élément, une couche, une itération de solveur — pour que le coût d’un check soit négligeable) :

pub fn pave(/* … */, cancel: &dyn Cancel) -> Result<…> {
    loop {
        cancel.check()?;          // ← point d'interruption
        // … une étape de travail …
    }
}

Sonder une fois par étape grossière suffit ; inutile de throttler par un compteur si chaque tour fait déjà un travail substantiel.

2. Exposer deux formes côté Rust — une simple et une interruptible — pour ne pas imposer un jeton aux appelants qui n’en veulent pas :

pub fn triangulate_surface(contour: &Mesh, et: ElementType, size: Option<f64>) -> Result<Mesh> {
    triangulate_surface_cancellable(contour, et, size, &NoCancel)
}

pub fn triangulate_surface_cancellable(
    contour: &Mesh, et: ElementType, size: Option<f64>, cancel: &dyn Cancel,
) -> Result<Mesh> { /* … boucle qui sonde `cancel` … */ }

Un programme Rust câble alors ce qu’il veut, sans toucher à Python :

#[test]
fn un_jeton_partage_interrompt_le_mailleur() {
    let coords = Handle::new(Coords::new(2).unwrap());
    let coins: Vec<Node> = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]
        .iter()
        .map(|p| Node::create_in(coords.clone(), p).unwrap())
        .collect();
    let mut sm = SubMesh::new(coords.clone(), ElementType::SEG2);
    for i in 0..4 {
        sm.add_cell(&[coins[i].id(), coins[(i + 1) % 4].id()])
            .unwrap();
    }
    let contour = Mesh::from_submesh(sm);

    let stop = Arc::new(AtomicBool::new(false));
    // A Ctrl+C handler (the `ctrlc` crate, to add to your own Cargo.toml), a
    // supervising thread, a timeout… all arm the same token:
    //     let s = stop.clone();
    //     ctrlc::set_handler(move || s.store(true, Ordering::Relaxed)).ok();

    let mesh = triangulate_surface_cancellable(&contour, ElementType::TRI3, Some(0.5), &*stop);
    assert!(mesh.is_ok()); // rien n'a armé le jeton : le maillage aboutit

    // Token armed in advance: the mesher stops at the first checkpoint.
    stop.store(true, Ordering::Relaxed);
    let interrompu =
        triangulate_surface_cancellable(&contour, ElementType::TRI3, Some(0.5), &*stop);
    assert!(interrompu.is_err());
    // `Deadline::after(Duration::from_secs(10))` would work just as well.
}

3. Brancher le jeton Python dans la couche FFI (src/py, gatée python-api) — le seul endroit où l’interruption Python rencontre le cœur :

pub struct PySignals<'py>(pub Python<'py>);

impl Cancel for PySignals<'_> {
    fn check(&self) -> Result<()> {
        self.0
            .check_signals()
            .map_err(|_| PyrucastError::Interrupted)
    }
}

Le paramètre py: Python<'_> est injecté par PyO3 et n’apparaît pas dans la signature Python : pyrucast.mesh.triangulate_surface(contour, element_type, size=None) reste inchangée, mais un Ctrl+C l’interrompt désormais.

Lien avec le parallélisme

Le même AtomicBool est le mécanisme naturel pour interrompre un calcul parallèle à mémoire partagée : chaque worker sonde le drapeau, et le thread principal (seul à détenir le GIL côté Python) le lève quand check_signals détecte le Ctrl+C. Poser le trait Cancel dès maintenant prépare ce terrain sans coût supplémentaire.

À retenir

  • L’interruption est coopérative : l’opérateur sonde, le frontend décide.
  • Le cœur reste PyO3-free (&dyn Cancel) → utilisable en Rust pur.
  • PyrucastError::Interrupted → KeyboardInterrupt côté Python.
  • Sonder une fois par étape grossière ; coût nul pour NoCancel (inliné).