Conventions & philosophie
Trois natures de types
L’arborescence sépare trois choses que le mot « objet » confond, et c’est elle qui rend la convention lisible sans qu’on ait à la relire :
- conteneurs (
containers/) — ce dont une partie est encore de la même nature : la moitié d’un maillage est un maillage, la moitié d’un champ est un champ. Les sept agrégats et leurs vuesSub*. Seul un conteneur peut être le sujet d’un opérateur ; - atomes (
atoms/) — les insécables :Node,Cell,Element(désignateurs),ElementType,Point3,RgbColor(valeurs). Un atome compose (node | node→Mesh) mais ne se décompose pas ; il est donc toujours argument, jamaisself. Piège à connaître :Cells’indexe (cell[k]→Node) sans être un conteneur, car ses parties sont d’une autre nature ; - magasin (
coords.rs) —Coords, ni conteneur ni atome : il contient des nœuds, d’une autre nature que lui, et n’offre ni[], ni|, ni vueSub*.
Méthodes vs fonctions libres
La règle qui décide si une opération est une méthode d’un conteneur ou
une fonction libre d’un module ops/ est figée dans le fichier
CONVENTIONS.md à la racine du dépôt. En résumé :
- méthode — accesseur, mutation préservant l’invariant, ou vue dérivée
d’un seul conteneur (
mesh.cell_count,field.set(...),field.components()) ; - fonction libre
ops::<module>— tout opérateur qui croise des conteneurs ou appartient à une famille d’opérateurs (ops::node_field::restrict(field, mesh),ops::matrix::stiffness(model, mat),ops::mesh::consolidate(mesh)).
Un constructeur nommé échappe aux deux : il fabrique son propre type, il
reste donc sur le type (FiniteElementSpace.lagrange1(mesh),
Matrix.block(...)). Mais l’exception s’arrête au pluriel : une famille
qui grossit — le catalogue de physiques d’un Model, 28 entrées — se range
comme toute famille d’opérateurs, dans le module du conteneur produit. Deux
raisons, l’une de principe, l’autre très concrète : en Python une
classmethod s’atteint aussi depuis une instance, si bien que
m.heat_conduction(fes) s’exécute en jetant m en silence, et
l’auto-complétion d’un objet se remplit de verbes qui ne s’adressent pas à
lui. D’où pyrucast.model.heat_conduction(fes) et
non Model.heat_conduction(fes) (rupture du 2026-08-25).
Où vit une fonction libre : le conteneur produit
Un module d’ops/ rassemble les opérateurs qui produisent le même
conteneur, et porte son nom. On range par la sortie, jamais par
l’entrée : gradient(field, fespace) produit un ElementField, donc il vit
dans element_field, à côté de deformation — pas dans node_field.
| module | produit |
|---|---|
mesh | un Mesh (mailleurs, transformations, select) |
node_field | un NodeField (dérivations, assemblage nodal) |
element_field | un ElementField (cinématique, matériaux, comportement) |
matrix | une Matrix (les assembleurs) |
model | un Model (les déclarations de physique) |
coords | écrit dans le magasin (set, displace) |
Ceux qui ne produisent aucun conteneur échappent à la règle par
construction et se rangent par activité : measure (réductions à un
nombre), geom (requêtes géométriques), export (effets de bord).
Troisième cas : l’opérateur générique, dont le produit est bien un
conteneur mais pas un conteneur déterminé — abs rend un NodeField ou
un ElementField selon son argument, la règle ne désigne donc pas un
module. Ceux-là se rangent par domaine (field) et restent des fonctions
libres à part entière.
Une seule exception, nommée et assumée : solver produit un NodeField
et devrait rejoindre node_field. Il garde son nom parce que plusieurs
familles distinctes produisent un champ nodal — dérivation, assemblage,
résolution — et que seule la résolution se cherche par son propre nom.
Corollaire pratique : un module ne contient jamais deux opérateurs qui ne
diffèrent que par leur conteneur. Le qualificatif appartient au nom du
module, pas à celui de la fonction — d’où trois consolidate homonymes et
sans ambiguïté, au lieu de trois fonctions suffixées au même endroit.
Le verbe exposé aussi en méthode
Une fonction libre garde sa forme canonique — c’est elle qui est documentée et qui définit l’opération. Elle est en plus exposée comme méthode de son premier argument si, et seulement si, les trois conditions tiennent :
- le premier argument est le sujet — l’objet qu’on transforme ;
- le retour est un conteneur — sinon il n’y a rien à composer ;
- l’opération a un sens pour toute instance du type.
La troisième est celle qu’on oublie, et c’est la plus coûteuse : une méthode
promet, elle apparaît dans l’auto-complétion de chaque objet du type.
u.deformation(fes) s’afficherait sur tous les champs nodaux alors qu’elle
exige des composantes u_x/u_y/u_z ; t.thermal_strain(...) sur tous les
champs par éléments alors qu’elle exige une température. Ces opérations restent
des fonctions libres seules.
La ligne de partage : une précondition structurelle est admise
(triangulate_surface veut un contour fermé), une exigence de sens porté par
les noms de composantes ne l’est pas — à moins que le nom cherché ne soit un
argument, ce qui la rend structurelle à son tour : divergence(field, "sigma") ne promet rien sur field, il dit quoi y chercher.
Pour tester la condition sans se tromper, lire la méthode avec un receveur
quelconque, pas avec l’exemple bien nommé : stresses.internal_forces()
sonnerait juste, mais c’est le nom de la variable qui ferait le travail —
field.internal_forces() révèle que le type ne promet rien. Son sujet est le
modèle, qui promet ses physiques, et c’est là que la méthode vit.
# chaining, when the three conditions hold:
peau = maillage.skin().consolidate()
libre = champ.select(ge=0.0)
eps = u.gradient(fes)
# forme canonique seule, sinon :
eps = pyrucast.element_field.deformation(u, fes) # exige un déplacement
f = pyrucast.node_field.merge(a, b) # symétrique : `a | b` suffit
Deux conséquences à retenir. Un ordre d’arguments qui ne met pas le sujet en
tête est un défaut à corriger, pas une raison de renoncer à la méthode.
Et une opération symétrique n’a pas de méthode : a.merge(b) suggérerait
que l’ordre compte, alors que merge est l’alias nommé de a | b.
Enfin, le nom peut changer entre les deux formes. Le nom complet est toujours
« qualificatif + verbe » ; la fonction libre reçoit le qualificatif de son
module, la méthode n’en a pas et doit le porter : matrix.stiffness(model, mats) d’un côté, model.stiffness_matrix(mats) de l’autre. Quand la sortie
est du type du sujet il n’y a rien à qualifier, et le nom ne bouge pas
(mesh.consolidate(m) / m.consolidate()).
Le miroir Python
Le binding Python est un miroir 1:1 : une fonction Rust devient une
fonction Python, une méthode reste une méthode. On vise le style
numpy/scipy (et l’héritage cast3m) — des opérateurs nommés plutôt que
des chaînes de méthodes. Le module de production est reflété par un
sous-module : une fonction libre ops::<module>::f est exposée comme
pyrucast.<module>.f. Les conteneurs et les atomes restent des classes au
top-level (pyrucast.Coords, pyrucast.Mesh, pyrucast.Node, …) :
import pyrucast
# functions (operators), laid out by produced container — not methods:
poi = pyrucast.mesh.to_poi1(mesh)
coords = pyrucast.node_field.positions(mesh)
eps = pyrucast.element_field.deformation(u, fes)
K = pyrucast.matrix.stiffness(model, materials)
sol = pyrucast.solver.solve(K, rhs)
Le miroir est sans exception : aucune fonction libre ne vit au top-level
Python. Les trois consolidations suivent leur module —
pyrucast.mesh.consolidate, pyrucast.node_field.consolidate,
pyrucast.element_field.consolidate — comme leurs homologues Rust.
L’extension compilée _pyrucast est, elle, plate : les homonymes y
portent un #[pyo3(name = …)] distinct (consolidate_mesh, …) et la couche
Python pure les ré-exporte sous leur vrai nom dans le bon sous-module. C’est
un détail du namespace privé, pas une entorse au miroir.
Un accesseur sans argument qui rend un scalaire — une taille, une
dimension, un nom, un drapeau — se lit comme une propriété, sans
parenthèses : mesh.cell_count, fes[0].gauss_count, fes[0].element_type,
zone.is_sealed. C’est l’idiome Python d’une valeur lue sur un objet, celui de
a.size ou a.ndim chez numpy. Ce qui prend un argument, ou rend une liste ou
un autre objet, reste une méthode : components(), coords(), mesh().
Erreurs
Toute l’API publique renvoie pyrucast::Result<T>, alias de Result<T, PyrucastError>.
PyrucastError est l’unique type d’erreur de la librairie. Côté Python, il est converti automatiquement en RuntimeError.
Rust :
#[test]
fn une_erreur_se_lit_et_se_filtre() {
// Dimension nulle — erreur attendue.
let err = Coords::new(0).unwrap_err();
assert!(err.to_string().contains("dim must be ≥ 1"));
// Pattern matching on the variants.
match Coords::new(0) {
Ok(_) => unreachable!(),
Err(pyrucast::PyrucastError::Message(msg)) => println!("erreur : {msg}"),
Err(e) => println!("autre erreur : {e}"),
}
}
Python :
import pyrucast
try:
c = pyrucast.Coords(0) # dimension nulle
except RuntimeError as e:
print(f"erreur : {e}") # erreur : dim must be ≥ 1
Sérialisation : un seul mécanisme
Le trait Portable (implémenté automatiquement pour tout type serde::Serialize + DeserializeOwned) produit un format binaire portable Linux ↔ Windows. C’est le socle de la sauvegarde fichier : un graphe d’objets écrit dans un conteneur versionné, relu en préservant le partage.
Rust — sérialisation manuelle d’un type quelconque :
use pyrucast::archive::Portable;
use pyrucast::models::symmetry::MaterialSymmetry;
#[derive(serde::Serialize, serde::Deserialize, PartialEq, Debug)]
struct Pt {
x: f64,
y: f64,
}
#[test]
fn un_seul_mecanisme_de_serialisation() {
let original = Pt { x: 1.5, y: -2.0 };
let bytes = original.to_bytes().unwrap();
let restored = Pt::from_bytes(&bytes).unwrap();
assert_eq!(original, restored);
}
Python :
Portablen’est pas exposé côté Python — c’est une brique interne. La sérialisation depuis Python passe parpyrucast.save/pyrucast.load, qui écrivent un graphe entier plutôt qu’un objet isolé.
Definition of Done par objet
Un objet n’est considéré comme terminé que lorsque les six points suivants sont verts :
- Struct Rust adressable par un
Handle<T>typé. - Les trois niveaux de listing :
Display(résumé),Debug(structure),Dump(contenu). - Tests unitaires Rust, et un doctest portant un exemple exécutable sur
chaque item public —
ignoreproscrit,no_runsi l’exemple ne peut pas tourner. - Binding PyO3 (
__str__/__repr__/dump()). - Tests Python (
tests/python/), la surface pyo3 comprise. - Chapitre de cette documentation, dont le code est inclus depuis un test ou un exemple, jamais recopié dans la page.
Les points 3, 5 et 6 sont détaillés page Documentation et tests : quel type d’exemple vit où, et qui le vérifie.
Dépendances approuvées
Le socle figé est :
- toujours lié —
serde+bincode(persistance),nalgebra+nalgebra-sparse(primitives et stockage creux),faer(LU creux du solveur),rayon(parallélisme),parking_lot(verrous des objets),paste(macros d’agrégat) ; - optionnel, derrière une feature —
pyo3etpyo3-stub-gen(binding et stub),plotters/winit/softbuffer(visualisation) ; - outillage —
maturin,mdbook,ruff,criterion(bancs).
Chaque dépendance est confinée à un étage et ne fuit pas hors de lui — le graphe des dépendances dit lequel pour chacune. Toute autre dépendance, Rust ou Python, requiert un accord explicite.