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

Champ aux nœuds (NodeField/SubNodeField)

Un champ aux nœuds porte des valeurs par nœud : une température, un déplacement, des forces nodales, les positions des nœuds. Là où un champ aux points de Gauss donne une valeur à chaque point d’intégration de chaque maille, celui-ci en donne une à chaque nœud, que les mailles voisines partagent. C’est un agrégat de zones :

  • une zone (SubNodeField) range les valeurs des nœuds d’un nuage de nœuds, une zone de maillage POI1 ;
  • un champ (NodeField) est une liste de zones, qui n’ont pas forcément les mêmes composantes : un champ multiphysique porte T sur tout le domaine et ux, uy sur la partie solide seulement.

Les règles communes aux deux familles de champs (composantes, règles de cohérence, statistiques, arithmétique) sont décrites dans Champ ; cette page en donne ce qui est propre aux nœuds.

Créer et lire un champ

pyrucast.NodeField(maillage, ["T"]) crée une zone par zone du maillage, avec ces composantes, toutes les valeurs à zéro ; sur une seule zone de maillage (SubMesh), le champ a une zone. NodeField.with_components_per_submesh(maillage, [["T", "ux"], ["T"]]) donne une liste de composantes par zone. Une liste vide, un nom en double, un nombre de listes qui ne correspond pas aux zones ou un maillage qui liste deux fois la même zone sont refusés.

Le champ se lit nœud par nœud : champ.value(nœud, "T") lit la première zone qui porte ce nœud et cette composante, et lève une erreur si aucune ne les porte. champ.values(nœuds, "T") lit plusieurs nœuds d’un coup et rend leurs valeurs dans le même ordre ; nœuds est une liste de Node, ou une zone ou un maillage dont la connectivité est lue dans l’ordre (pour une zone POI1, ses points). Un nœud d’un autre Coords est refusé.

On écrit dans une zone : champ[i] ou champ.unit() la rendent, et ce sont des vues (écrire dans la zone, c’est écrire dans le champ). Dans une zone, un nœud se désigne par lui-même (zone[nœud, "T"], set_value) ou par sa ligne, son rang dans le support de la zone (get(ligne, rang), set, node_values). Les opérations par composante, comme add_to_component, s’appliquent au champ entier, en place, dans toutes les zones qui portent la composante.

import pyrucast

# Un carré coupé en deux triangles, deux zones de maillage qui partagent la
# diagonale a–d.
c = pyrucast.Coords(2)
a, b, d, e = (c.add_node(p) for p in ([0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]))
bas = pyrucast.Mesh(c, "TRI3")
bas.unit().add_cell([a, b, d])
haut = pyrucast.Mesh(c, "TRI3")
haut.unit().add_cell([a, d, e])
carre = bas | haut

# Une zone par zone de maillage, toutes les valeurs à zéro.
T = pyrucast.NodeField(carre, ["T"])
print(len(T), T.node_count)  # 2 4 — a et d sont rangés dans les deux zones

# En place, dans toutes les zones qui portent T ; puis dans une zone seulement.
T.add_to_component("T", 20.0)
T[1].set_value(e, "T", 25.0)  # e n'appartient qu'à la zone du haut

# Lire par le champ : la première zone qui porte le nœud répond.
print(T.value(e, "T"))  # 25.0
print(T.values([a, b, d, e], "T"))  # [20.0, 20.0, 20.0, 25.0]
print(T.sum("T"))  # 85.0 — a et d ne comptent qu'une fois

# Une zone est une vue : écrire dans zone, c'est écrire dans T.
zone = T[0]
print(zone)  # SubNodeField: 3 node(s), 1 component(s) [T]
print(zone[d, "T"], zone.get(2, 0))  # 20.0 20.0 — d est la ligne 2 de la zone

# Écrire un nœud partagé dans une seule zone rompt la deuxième règle.
zone[d, "T"] = 30.0
try:
    T.check()
except RuntimeError as err:
    print(err)  # incoherent NodeField: node 2, component T: 30 ≠ 20
T[1][d, "T"] = 30.0
T.check()  # les deux zones s'accordent de nouveau

# Une liste de composantes par zone.
mixte = pyrucast.NodeField.with_components_per_submesh(carre, [["T", "ux"], ["T"]])
print(mixte.components())  # ['T', 'ux']
NodeField
len(champ), champ[i], for zone in champ, unit(), |, add_subl’interface des agrégats
support()le support de chaque zone, dans l’ordre, partagé
components()l’union des composantes des zones
value(nœud, "c"), values(nœuds, "c")une valeur, ou une liste dans l’ordre des nœuds
node_countle nombre de nœuds distincts, toutes zones confondues
min(c), max(c), sum(c)sur toutes les zones qui portent c ; sum compte une fois un nœud partagé
champ["c"], filter_components([…]), rename_component(a, b)un champ réduit à ces composantes, ou une composante renommée
add_to_component(c, s), mul_to_component(c, s)…une opération sur une composante, en place
check()vérifie que les zones s’accordent sur les nœuds qu’elles partagent
consolidate()fond les zones posées sur un même support (voir Combiner)
plot(…)le tracé, voir Visualisation
SubNodeField (zone)
support()son nuage de nœuds, partagé
node_count, component_countses deux dimensions
value(nœud, "c"), zone[nœud, "c"]une valeur, par nœud et nom de composante
set_value(nœud, "c", v), zone[nœud, "c"] = vécrire une valeur
get(ligne, rang), set(ligne, rang, v)par ligne et rang de composante
get_by_node(nœud, rang), set_by_node(nœud, rang, v)par nœud et rang de composante
node_values(ligne)toutes les composantes d’une ligne, dans l’ordre
set_uniform("c", v)la composante c à v en tout nœud
components(), component_index("c")les composantes, le rang de l’une

Le support : un nuage de nœuds

Chaque zone est posée sur une zone de maillage POI1 : une liste de nœuds, un par maille. La ligne i de la zone est le i-ème nœud de ce support ; le champ ne range que ses valeurs et lit ses nœuds dans le support.

  • Sur une zone POI1, le champ prend cette zone pour support, telle quelle. Il la refuse si elle liste deux fois un même nœud, puisqu’il range une ligne par nœud ; pyrucast.mesh.to_poi1 en donne les nœuds distincts.
  • Sur une zone d’un autre type (TRI3, HEX8…), il prend son nuage de nœuds : la zone POI1 de ses nœuds distincts, dans leur ordre de première apparition, celle que rend pyrucast.mesh.to_poi1. La zone de maillage le calcule une fois et le garde en cache : deux champs construits sur le même maillage reçoivent le même support.

Le support est scellé, puisque le champ range ses lignes dans l’ordre de ses nœuds. La zone TRI3 d’où vient le nuage, elle, reste modifiable. Si on lui ajoute une maille, elle oublie son nuage : les champs déjà posés restent valides sur l’ancien, et un champ construit ensuite se pose sur un nouveau nuage. Les deux ne se combinent plus nœud à nœud ; pyrucast.node_field.restrict(champ, maillage) porte l’ancien sur le nouveau (voir Maillage).

champ.support() rend le support de chaque zone, dans l’ordre, et zone.support() celui d’une zone. C’est le même objet, pas une copie : un champ construit dessus se combine nœud à nœud avec le premier. Le champ garde son support en vie, mais pas la zone de maillage dont il a été tiré : il n’a plus besoin d’elle.

# Posé sur une zone TRI3, le champ prend son nuage de nœuds : une zone POI1,
# scellée. La zone TRI3 elle-même reste modifiable.
s = T.support()
print(s[0].element_type, s[0].cell_count)  # POI1 3
print(s[0].is_sealed, carre[0].is_sealed)  # True False

# Ce nuage est celui que rend to_poi1, le même objet : l'union ne garde
# que deux zones, pas quatre.
print(len(s | pyrucast.mesh.to_poi1(carre)))  # 2

# Une zone POI1 est elle-même le support.
points = pyrucast.mesh.poi1_from_nodes([b, d])
F = pyrucast.NodeField(points, ["F"])
print(len(F.support() | points), points[0].is_sealed)  # 1 True

Nœuds partagés entre zones

Deux zones de maillage qui se touchent partagent leurs nœuds de frontière, et un champ posé sur elles range ces nœuds dans chaque zone, comme a et d dans le premier exemple. La deuxième règle de cohérence veut qu’un tel nœud ait la même valeur dans toutes les zones qui le portent, pour chaque composante qu’elles ont en commun. Le champ s’appuie sur elle :

  • value et values lisent la première zone qui porte le nœud ;
  • sum, node_count, pyrucast.measure.xtx et xty comptent un nœud partagé une fois ;
  • add_to_component et les autres opérations par composante modifient toutes les zones à la fois, et gardent donc la règle.

L’union et les opérations entre deux champs la vérifient quand c’est nécessaire. Écrire dans une zone, en revanche, ne touche pas les autres : T[0][d, "T"] = 30.0 laisse l’ancienne valeur de d dans la zone du haut. champ.check() compare alors les valeurs exactement et lève une erreur au premier désaccord.

Combiner des champs

Les opérations par un scalaire et entre deux champs suivent les règles de Champ : les valeurs s’associent par support et par composante, et deux zones s’associent quand leurs supports sont le même objet.

L’union a | b réunit les zones sans toucher aux valeurs :

  • les zones posées sur un même support sont fondues en une, qui porte l’union de leurs composantes ; une composante portée des deux côtés doit y avoir les mêmes valeurs ;
  • les zones posées sur des supports distincts restent côte à côte, et doivent s’accorder sur les nœuds qu’elles partagent.

Un désaccord lève une erreur. pyrucast.node_field.merge(a, b) est cette même union. add_sub ajoute une zone telle quelle, sans rien fondre, et refuse une zone qui porterait une composante déjà portée sur son support ; consolidate() fond ensuite les zones qui partagent un support, après la même vérification.

# Sur un même support, l'union fond les zones en une.
q = pyrucast.NodeField(carre, ["q"])
tout = T | q
print([z.components() for z in tout])  # [['T', 'q'], ['T', 'q']]

# Deux supports distincts restent côte à côte, mais doivent s'accorder sur
# les nœuds qu'ils partagent.
coin = pyrucast.NodeField(pyrucast.mesh.poi1_from_nodes([d]), ["T"])
try:
    T | coin
except RuntimeError as err:
    print(err)  # incoherent NodeField: node 2, component T: 30 ≠ 0
coin.unit()[d, "T"] = 30.0
print(len(T | coin))  # 3

# add_sub ajoute une zone telle quelle ; consolidate fond celles qui
# partagent un support.
w = pyrucast.NodeField(carre, ["T"])
w.add_sub(q[0])
print(len(w), len(w.consolidate()))  # 3 2

D’où viennent ces champs, et où ils vont

Les opérateurs qui produisent un champ aux nœuds sont rangés dans pyrucast.node_field (détail dans Opérateurs sur les champs et Assemblage) :

FamilleOpérateurs
géométriepositions(maillage), les coordonnées des nœuds, une composante par axe
d’un champ aux points de Gaussdivergence, sum_to_nodes
assemblageinternal_forces, external_forces
d’un champ aux nœudsrestrict, restrict_like, project, merge, consolidate, mask

La résolution en produit un aussi : pyrucast.solver.solve(K, f) pose sa solution sur les supports des blocs de la matrice, ceux que restrict(…, maillage) donne sur le même maillage (voir Solveur).

Ces champs sont ensuite consommés :

  • portés aux points de Gauss : interp_to_gauss, gradient, deformation, beam_deformation, shell_deformation, strain (dans pyrucast.element_field) ;
  • comme second membre de solve, où un degré de liberté qu’aucune zone ne porte vaut zéro ;
  • pour déplacer les nœuds : pyrucast.coords.set, displace, displace_from, qui traitent une fois un nœud partagé ;
  • par pyrucast.model.update_contact, les réductions de pyrucast.measure (integral, xtx, xty), l’export (pyrucast.export.to_arrays(…, node_fields=[…]), export_vtk) et le tracé.

Ce qui est mis en cache

Un champ aux nœuds ne garde aucun cache : une zone ne contient que ses valeurs, les noms de ses composantes et son support. Il s’appuie sur deux caches des zones de maillage (détaillés dans Maillage) :

  • le nuage de nœuds, gardé par la zone de maillage d’origine, calculé à la première demande puis rendu tel quel. C’est lui qui donne le même support à NodeField(maillage, …), à restrict(…, maillage), à la solution de solve et aux sorties de divergence et de sum_to_nodes sur ce maillage : ils se combinent nœud à nœud. Il épargne aussi le dédoublonnage des nœuds de la zone à chaque nouveau champ ;
  • l’index des nœuds, gardé par le support : la table « nœud → ligne », construite à la première lecture par nœud (value, set_value, values, les opérateurs qui lisent le champ nœud par nœud). Elle sert ensuite à tous les champs posés sur ce support : trouver la ligne d’un nœud ne parcourt plus la liste.

Le support étant scellé, son index ne change plus. Le nuage, lui, est oublié dès que la zone de maillage d’origine change.

Visualisation

champ.plot(component="T") trace un nuage de points colorés : le support POI1 n’a pas de mailles, aucune surface ne s’en déduit. Pour colorer les mailles, on passe le champ au maillage : maillage.plot(field=champ, component="T"). Voir Visualisation.

Sauvegarde

pyrucast.save écrit le champ avec son support et son Coords. Des champs sauvegardés dans un même fichier partagent leur support à la relecture et se combinent nœud à nœud. Le nuage mis en cache n’est pas écrit : un champ construit sur le maillage relu se pose sur un nouveau nuage, et restrict(champ, maillage) y ramène un champ relu. Les nœuds relus appartiennent au Coords relu : value refuse un Node du script qui a écrit le fichier, et on lit avec le nœud relu, o["maillage"].coords().acquire(id).

Côté Rust

SubNodeField porte le Handle<SubMesh> de son support, ses noms de composantes et un Vec<f64> à plat, rangé ligne → composante :

values[ligne * component_count + c]

Les composantes d’un nœud sont donc contiguës : node_values(ligne) en rend la tranche sans copie.

Le Handle<SubMesh> du support est le compagnon POI1 lui-même, sans objet intermédiaire. SubMesh::to_poi1 garde ce compagnon dans un OnceLock et en rend un clone du Handle ; le champ garde ce clone. Un Handle étant un Arc<RwLock<SubMesh>>, le champ et la zone d’origine tiennent deux clones du même Arc, et le champ ne référence pas la zone d’origine.

NodeField est l’agrégat de Handle<SubNodeField>. Son check_push refuse une zone d’un autre Coords, son check_add une composante déjà portée sur le support de la zone (première règle), et son finalize, appelé à la fin de chaque union, est ops::node_field::consolidate.

#[test]
fn un_champ_aux_noeuds_s_ecrit_par_zone_et_se_lit_par_agregat() -> Result<()> {
    let coords = Handle::new(Coords::new(2)?);
    let n: Vec<Node> = [[0.0, 0.0], [1.0, 0.0], [0.0, 1.0]]
        .iter()
        .map(|p| Node::create_in(coords.clone(), p))
        .collect::<Result<_>>()?;
    let mut tri = Mesh::from_submesh(SubMesh::new(coords, ElementType::TRI3));
    tri.add_cell(&[n[0].id(), n[1].id(), n[2].id()])?;

    // Une zone par zone de maillage, posée sur son nuage de nœuds : le
    // `Handle` même que `to_poi1` garde en cache, sans objet intermédiaire.
    let u = NodeField::new(&tri, vec!["ux".into(), "uy".into()])?;
    let zone = u.get(0)?;
    let nuage = tri.get(0)?.read().to_poi1()?;
    assert!(zone.read().support().same_object(&nuage));

    // Écriture par la zone, lecture par le champ.
    zone.write().set_value(n[1].id(), "ux", 1.5)?;
    assert_eq!(u.value(n[1].id(), "ux")?, 1.5);
    assert_eq!(u.value(n[0].id(), "ux")?, 0.0); // valeur initiale
    u.check()?;
    Ok(())
}
  • Contrat commun. Les noms de composantes, min/max/sum, les opérations par composante et l’arithmétique viennent des traits SubField et Field (src/containers/field.rs), partagés avec le champ aux points de Gauss.
  • Ligne d’un nœud. SubNodeField::index_of passe par SubMesh::node_index(), un OnceLock<HashMap<NodeId, usize>> du support ; index_of_with prend un verrou de lecture déjà tenu, pour une boucle qui cherche beaucoup de nœuds.
  • Lecture par les opérateurs. NodeFieldView tient un verrou de lecture sur chaque zone et sur son support pendant tout un opérateur (gradient, restrict, project, le tracé…), et lit les valeurs en place.
  • Un nœud une fois. first_rows parcourt les zones dans l’ordre et donne chaque nœud une fois, à la première zone qui le porte : c’est ce qu’utilisent node_ids, node_count et le déplacement des nœuds.