Champ aux nœuds (NodeField / SubNodeField)
Un champ aux nœuds porte une ou plusieurs valeurs par nœud. Il suit la même grammaire d’agrégat que tous les conteneurs de pyrucast (cf. Conventions) :
SubNodeField— les valeurs d’une zone : un bloc multi-composantes sur les nœuds d’un sous-maillage POI1 (cf. Maillage) ;NodeField— l’agrégat : une liste deSubNodeField, un par zone, avec éventuellement des composantes différentes d’une zone à l’autre.
C’est le miroir exact de ElementField / SubElementField côté valeurs aux
nœuds. L’intérêt de l’agrégat : un champ multiphysique (par exemple T sur
tout le domaine, UX/UY sur la zone solide seulement) se représente sans
inventer de 0.0 pour les couples (nœud, composante) qu’aucune zone ne
définit — rien n’est densifié.
Support : un sous-maillage POI1 par zone
Un SubMesh POI1 est, par construction, exactement une liste de nœuds
(un nœud par cellule). Chaque SubNodeField s’appuie sur un support POI1 :
- construit depuis un SubMesh POI1, le champ partage le handle du support tel quel (aucun refcount par nœud supplémentaire : le SubMesh est l’unique propriétaire des increfs, garder son handle suffit à garder les nœuds en vie) ;
- construit depuis un SubMesh d’un autre type d’élément, le champ se pose sur
le compagnon POI1 canonique de la zone — ses nœuds distincts dans
l’ordre de première apparition —, matérialisé une fois et mémoïsé par le
sous-maillage. Deux champs bâtis sur la même zone tombent donc sur le même
support, et s’apparient (cf.
same_support).
Le champ ne stocke aucun identifiant de nœud : un POI1 est la liste de nœuds, et c’est le support qui la détient, une fois pour tous les champs qui s’y posent. Recopier la liste dans chaque champ reviendrait à la stocker autant de fois qu’il y a de champs (et de pas de temps) sur ce support ; le champ ne porte que ses valeurs, et lit les nœuds dans le support quand il en a besoin.
Scellement : le compagnon, pas le maillage
C’est le support qui est scellé — le compagnon POI1 —, jamais le maillage donné en argument. Le champ indexe ses lignes par position dans ce support, donc ce support ne doit plus bouger ; mais la zone d’origine, elle, n’a aucune raison de geler : le champ n’y touche plus.
Modifier cette zone (add_cell, remap_nodes) lui fait lâcher son
compagnon : le nuage de nœuds a changé, le cache n’y répond plus. Conséquence,
un champ construit après la modification se pose sur un nouveau support et
ne s’apparie plus avec les précédents. Ce n’est pas une invalidation : les
champs d’avant gardent leur support en vie et restent parfaitement lisibles,
simplement définis sur le maillage d’avant. Pour les ramener sur le nouveau, un
restrict suffit.
NodeField (agrégat)
├── SubNodeField zone 0 ── support POI1 ── values[i × ncomp + c]
├── SubNodeField zone 1 ── support POI1 ── values[...]
└── …
Composantes nommées
Chaque zone porte ses noms de composantes ("UX", "UY", "T", …),
rangés en row-major : la composante c du nœud i est à l’indice
i × ncomp + c. Au moins une composante par zone, noms uniques, valeurs
initialisées à 0.0. Au niveau agrégat, components() renvoie l’union
des composantes des zones (ordre de première apparition).
Les caractéristiques communes à tous les champs (composantes, min, max,
sum, arithmétique scalaire et par composante) sont portées par les traits Rust
SubField (niveau zone) et Field (niveau agrégat) — partagés
avec ElementField.
Nœuds d’interface : duplication, lecture, cohérence
Un nœud partagé par plusieurs zones (nœud d’interface) est stocké une fois par zone. Trois règles régissent cette duplication :
- lecture agrégat (
field.value(nœud, comp)) : la première zone définissant le couple gagne — aucune vérification au fil de l’eau. La forme par lotfield.values(nœuds, comp)lit une liste de valeurs dans le même ordre :nœudsest une liste de nœuds, unSubMeshPOI1, ou unMeshPOI1 (ses points pris dans l’ordre de la connectivité) ; même règle « première zone » et même erreur qu’un nœud non défini ; - écriture : il n’y a pas d’écriture au niveau agrégat ; toute
mutation passe par les zones (
field[i]), exactement commeElementField; - cohérence à la demande :
field.check()vérifie que toutes les zones stockant un même couple(nœud, composante)portent la même valeur (comparaison exacte) ;node_field.consolidate(field)fait cette vérification puis fusionne par support — les zones définies sur le mêmeSubMesh(identité de handle) deviennent une seule zone portant l’union de leurs composantes (valeurs des composantes communes vérifiées), les supports distincts restent séparés.
Composition : union |
Comme pour tous les agrégats, a | b unit les zones (handles partagés,
pas de copie ; déduplication par handle) — ce n’est pas une addition de
valeurs. L’union finalise en fusionnant les zones de même support (voir
node_field.consolidate ci-dessus) et lève si deux zones divergent sur une valeur
partagée. L’arithmétique scalaire (f + 2.0, f * 0.5, …) vit au niveau
zone (SubNodeField) sur +/*/… Le nommé merge(a, b) ≡ a | b.
API Rust
#[test]
fn un_champ_aux_noeuds_s_ecrit_par_zone_et_se_lit_par_agregat() -> Result<()> {
let coords = Handle::new(Coords::new(2)?);
let a = Node::create_in(coords.clone(), &[0.0, 0.0])?;
let b = Node::create_in(coords.clone(), &[1.0, 0.0])?;
// Support : SubMesh POI1 contenant a et b.
let sm = {
let mut sm = SubMesh::new(coords.clone(), ElementType::POI1);
sm.add_cell(&[a.id()])?;
sm.add_cell(&[b.id()])?;
Handle::new(sm)
};
// Champ de déplacement 2D mono-zone : composantes UX, UY.
let u = NodeField::from_submesh(&sm, vec!["UX".into(), "UY".into()])?;
// Écriture : via la zone. Lecture : via l'agrégat (ou la zone).
u.get(0)?.write().set_value(a.id(), "UX", 1.5)?;
assert_eq!(u.value(a.id(), "UX")?, 1.5);
assert_eq!(u.value(b.id(), "UX")?, 0.0); // valeur par défaut
// From a multi-zone mesh: one SubNodeField per submesh.
let mesh = Mesh::from_submesh(SubMesh::new(coords, ElementType::POI1));
let field = NodeField::new(&mesh, vec!["T".into()])?;
assert_eq!(field.len(), mesh.len());
field.check()?; // zones cohérentes aux interfaces
Ok(())
}
API Python
import pyrucast
c = pyrucast.Coords(dim=2)
a = c.add_node([0.0, 0.0])
b = c.add_node([1.0, 0.0])
mesh = pyrucast.Mesh(c, "POI1")
mesh.unit().add_cell([a])
mesh.unit().add_cell([b])
# One SubNodeField per submesh of the support (Mesh or SubMesh).
u = pyrucast.NodeField(mesh, ["UX", "UY"])
print(u) # NodeField: 1 subfield(s)
print(u.unit()) # SubNodeField: 2 node(s), 2 component(s) [UX, UY]
# Écriture via la zone, lecture via l'agrégat.
u[0][a, "UX"] = 1.5
print(u.value(a, "UX")) # 1.5
# Batch read: a list of nodes (or a POI1 Mesh/SubMesh) → an ordered list.
print(u.values([a, b], "UX")) # [1.5, 0.0]
print(u.values(mesh, "UX")) # [1.5, 0.0] — points of the POI1 mesh
print(u.min("UX"), u.max("UX")) # 0.0 1.5
print(u.sum("UX")) # 1.5 — Σ over the nodes (resultant of a force field)
# Components per zone (multiphysics):
f = pyrucast.NodeField.with_components_per_submesh(two_zone_mesh, [["T"], ["UX", "UY"]])
print(f.components()) # ['T', 'UX', 'UY']
f.check() # interface coherence (raises otherwise)
g = pyrucast.node_field.consolidate(f) # merge as tightly as possible
Refcount et durée de vie
Le champ ne fait aucune comptabilité par nœud : il garde un clone du
Handle<SubMesh> de son support, et c’est le SubMesh qui possède les
increfs par nœud dans la Coords (cf.
Coords). Tant qu’une zone du champ est vivante,
son support l’est aussi, donc ses nœuds aussi — même si tous les Node
utilisateurs ont disparu.
La libération est automatique : quand le dernier Handle sur une zone
disparaît, la zone est détruite, son clone du handle de support avec elle, et
les nœuds redeviennent collectables si plus rien ne les retient (cf.
Modèle mémoire).
Opérateurs consommant un champ
Les opérateurs détaillés sont décrits dans
Opérateurs sur les champs (dérivations),
Assemblage (flux, second membre) et
Solveur. Tous consomment l’agrégat et résolvent les
nœuds à travers les zones (règle premier-trouvé) :
| Opération | Particularité multi-zones |
|---|---|
positions(mesh) | un SubNodeField par submesh, interfaces cohérentes par construction |
coords.set(f) / displace(f) | chaque nœud distinct traité une seule fois (un nœud d’interface n’est pas déplacé deux fois) |
gradient(f, fes) / deformation(u, fes) | lookups par nœud × Gauss via un snapshot des zones |
divergence(F) | adjoint de gradient : champ vectoriel par éléments → NodeField (div), accumulé par nœud (d_i = ∫ ∇N_i·F) |
solve(matrix, rhs) | second membre lu par DOF (absent ⇒ 0.0) ; solution : une zone par support colonne des blocs de la matrice, sur le handle même du bloc — same_support avec tout champ posé sur ces supports, stable d’une résolution à l’autre |
restrict(f, mesh) | une zone par submesh cible, sur le nuage POI1 canonique caché du sous-maillage (to_poi1) ⇒ deux restrictions sur le même mesh partagent le support et sont soustractibles (et s’alignent avec K·x/solve) ; 0.0 pour les nœuds non couverts |
restrict_like(f, target) | reprojette sur le support et les composantes de target (mêmes slots) ⇒ combinable par + - * / avec target ; nœuds/composantes hors de target abandonnés, 0.0 si non couverts |
merge(a, b) | union structurelle consolidée (conflit de valeur ⇒ erreur) |
node_field.consolidate(f) | fusion par jeu de composantes après vérification de cohérence |