Évolution (Evolution/SubEvolution)
Une évolution associe des valeurs à des abscisses et interpole linéairement entre elles. L’abscisse est souvent le temps, mais pas forcément : une température, une déformation, ou plusieurs variables à la fois. La valeur est un nombre, un champ aux nœuds ou un champ aux points de Gauss.
C’est un agrégat :
- une
SubEvolutionest une courbe : la liste de ses abscisses et la liste des valeurs en regard. L’interpoler enxrend une valeur ; - une
Evolutionréunit desSubEvolution, une par zone, comme unNodeFieldréunit ses zones. L’interpoler interpole chaque courbe et regroupe les résultats : unNodeFieldou unElementFieldpour des champs, une liste de flottants pour des nombres.
Une courbe
SubEvolution(échantillons) prend une liste de couples (abscisse, valeur).
Les échantillons sont triés à la construction ; deux à la même abscisse sont
refusés. Entre deux échantillons x₀ ≤ x ≤ x₁, la valeur est
v₀ + (v₁ − v₀)·(x − x₀)/(x₁ − x₀) ; sur un échantillon, c’est sa valeur.
import pyrucast
# Une température qui monte de 20 à 120 °C en 10 s, puis reste à 120 °C.
montee = pyrucast.SubEvolution([(0.0, 20.0), (10.0, 120.0), (60.0, 120.0)])
print(montee.interpolate(2.5)) # 45.0
print(montee.interpolate(10.0)) # 120.0 : un échantillon, rendu tel quel
print(montee.abscissas()) # [0.0, 10.0, 60.0]
print(len(montee)) # 3
Hors plage
Une abscisse en dehors de la plage tabulée suit la politique hors plage de l’évolution :
| Politique | Hors plage |
|---|---|
"error" (par défaut) | lève une erreur |
"clamp" | rend la valeur de l’extrémité la plus proche |
"extrapolate" | prolonge le premier ou le dernier segment |
La politique se donne à la construction (out_of_range="clamp"), se lit par
out_of_range, et s’impose pour un seul appel :
interpolate(x, out_of_range="clamp").
try:
montee.interpolate(70.0) # politique "error", par défaut
except RuntimeError as erreur:
print(erreur) # evolution: abscissa 70 is outside the tabulated range [0, 60]
print(montee.interpolate(70.0, out_of_range="clamp")) # 120.0
print(montee.interpolate(-1.0, out_of_range="extrapolate")) # 10.0
bornee = pyrucast.SubEvolution([(0.0, 20.0), (10.0, 120.0)], out_of_range="clamp")
print(bornee.out_of_range, bornee.interpolate(70.0)) # clamp 120.0
Des champs comme valeurs
Evolution([(t₀, champ₀), (t₁, champ₁), …]) prend un champ entier par
abscisse, un NodeField ou un ElementField. Elle en fait une courbe par
zone : les zones sont celles du premier champ, et chaque champ suivant doit
porter une zone sur chacun de ces supports ; une zone qu’il aurait en plus est
ignorée. Toutes les valeurs d’une courbe ont le même support et les mêmes
composantes.
L’évolution copie les valeurs des champs ; le support, lui, est partagé. Modifier un champ ensuite ne change pas l’évolution, et le champ interpolé est posé sur le même support que les champs d’origine : il se combine avec eux zone à zone.
shared_abscissas() rend les abscisses, communes à toutes les zones, et
frames() les champs tabulés, un par abscisse.
c = pyrucast.Coords(1)
noeuds = [c.add_node([float(i)]) for i in range(3)]
support = pyrucast.mesh.poi1_from_nodes(noeuds)
froid = pyrucast.NodeField(support, ["T"])
froid.unit().set_uniform("T", 20.0)
chaud = pyrucast.NodeField(support, ["T"])
chaud.unit().set_uniform("T", 120.0)
chauffe = pyrucast.Evolution([(0.0, froid), (10.0, chaud)])
t = chauffe.interpolate(2.5) # un NodeField, sur le même support
print(t.value(noeuds[0], "T")) # 45.0
print(chauffe.shared_abscissas()) # [0.0, 10.0]
print(len(chauffe.frames())) # 2 : les champs tabulés, un par abscisse
Une abscisse à plusieurs variables
Une abscisse peut être un tuple de deux à quatre nombres : la courbe
devient une table. Chaque échantillon est alors ((T, φ), valeur), et
interpolate((T, φ)) rend la valeur en ce point.
# Limite d'élasticité σ_y (MPa) selon la température T et la fraction φ :
# deux valeurs de φ mesurées à 20 °C, trois à 500 °C.
sigma_y = pyrucast.SubEvolution(
[
((20.0, 0.0), 300.0),
((20.0, 1.0), 450.0),
((500.0, 0.0), 200.0),
((500.0, 0.5), 260.0),
((500.0, 1.0), 320.0),
],
abscissa_type=("T", "phi"),
ordinate_type="sigma_y",
)
print(sigma_y.interpolate((300.0, 0.4))) # 294.666…
La table se lit variable après variable. Les échantillons sont rangés par
leur première variable : ici, un groupe à 20 °C (φ = 0 et 1), un autre à
500 °C (φ = 0, 0,5 et 1). Chaque groupe est une courbe le long de la variable
suivante. En (300, 0,4) :
φ
1 ● 450 ● 320
│ │
0,5 │ ● 260
0,4 ◆ 360 ─────────────── ★ ─────────── ◆ 248
│ 294,7 │
0 ● 300 ● 200
└────────────────────┬──────────────┴───── T
20 300 500
● échantillons de la table
◆ étape 1 : chaque groupe est lu le long de φ, en φ = 0,4
★ étape 2 : les deux résultats sont pesés le long de T, en T = 300
- On cherche les deux groupes qui encadrent
T = 300: 20 °C et 500 °C. - Chacun est interpolé le long de φ en
0,4: entre 300 et 450 à 20 °C, ce qui donne 360 ; entre 200 et 260 à 500 °C (φ de 0 à 0,5), ce qui donne 248. - On interpole entre ces deux résultats le long de T :
t = (300 − 20)/(500 − 20) ≈ 0,583, et360 + 0,583 × (248 − 360) ≈ 294,7.
Pour deux variables, avec T_a ≤ T ≤ T_b les groupes encadrants et f_a,
f_b leurs courbes en φ :
\[ f(T, \varphi) = (1-t)\, f_a(\varphi) + t\, f_b(\varphi), \qquad t = \frac{T - T_a}{T_b - T_a}. \]
Avec trois ou quatre variables, chaque groupe se lit de la même façon le long de la variable suivante. Ce que cela donne :
- les groupes n’ont pas à partager leurs abscisses : la table ci-dessus mesure deux valeurs de φ à 20 °C et trois à 500 °C ;
- sur une grille complète, le résultat est l’interpolation
multilinéaire (bilinéaire pour deux variables). Une fonction linéaire en
chaque variable séparément, comme
a + bT + cφ + dTφ, est retrouvée exactement, grille complète ou non ; - un groupe où la variable suivante ne prend qu’une valeur est constant le long d’elle : une propriété mesurée pour une seule valeur de φ à une température donnée ne dépend pas de φ à cette température ;
- l’ordre des variables compte : la première range les groupes, et c’est le long d’elle que se fait le dernier mélange. Sur une table dont les groupes ne partagent pas leurs abscisses, échanger l’ordre des variables change le résultat ;
- hors de la plage d’une variable, la politique hors plage s’applique à cette variable, dans le groupe où elle est lue.
abscissas() rend les abscisses triées : des nombres pour une courbe, des
tuples pour une table, rangés par première variable puis par la suivante.
Types d’abscisse et d’ordonnée
Une courbe peut porter le nom de ce que mesurent ses axes :
abscissa_type, le nom de l’abscisse ("T","t"), un tuple de noms pour une table (("T", "phi")), un par variable ;ordinate_type, le nom de la valeur ("E"), pour une courbe de nombres seulement : un champ a déjà ses composantes, et le donner à une évolution de champs est une erreur.
Ils se donnent à la construction et se lisent par abscissa_type() et
ordinate_type() ; ceux d’une Evolution sont ceux de sa première courbe.
Ils étiquettent les tracés et relient une courbe à un champ (section
suivante).
young = pyrucast.SubEvolution(
[(20.0, 210e3), (500.0, 170e3)], abscissa_type="T", ordinate_type="E"
)
print(young.abscissa_type(), young.ordinate_type()) # T E
print(sigma_y.abscissa_type()) # ('T', 'phi')
Appliquer une courbe à un champ
Une courbe de nombres s’applique à un champ comme cos : interpolate
reçoit un champ au lieu d’une abscisse, et rend un champ sur le même support,
dont chaque nœud ou point de Gauss est la courbe évaluée en ses propres
valeurs.
- La courbe lit les composantes nommées comme son
abscissa_type, une par variable. Sansabscissa_type, ou si le champ n’a pas ces composantes, c’est une erreur. - Elle écrit une composante, nommée d’après son
ordinate_type, ou"value"à défaut. - La politique hors plage s’applique point par point.
etat = pyrucast.NodeField(support, ["T", "phi"])
for noeud, (T, phi) in zip(noeuds, [(20.0, 0.0), (260.0, 0.4), (500.0, 1.0)]):
etat.unit().set_value(noeud, "T", T)
etat.unit().set_value(noeud, "phi", phi)
E = young.interpolate(etat[0]) # lit la composante "T", écrit "E"
print(E.components(), E.value(noeuds[1], "E")) # ['E'] 190000.0
Une Evolution applique toutes ses courbes : k courbes donnent un champ
à k composantes, chacune lisant ses propres variables. Deux courbes qui
écriraient la même composante sont refusées. C’est ainsi qu’on calcule des
propriétés matériau qui dépendent de l’état, par exemple E(T) et σ_y(T, φ)
aux points de Gauss en un seul appel, avant de les réunir au besoin aux
propriétés constantes ; il n’y a pas d’opérateur matériau à part.
lois = young | sigma_y # deux courbes : E(T) et σ_y(T, φ)
materiau = lois.interpolate(etat) # chaque nœud, en ses propres T et φ
print(materiau.components()) # ['E', 'sigma_y']
print(materiau.value(noeuds[1], "sigma_y")) # 304.0
Composer des courbes
| réunit des SubEvolution en une Evolution, une courbe par zone. Toutes
portent le même genre de valeur : des nombres, ou des champs aux nœuds, ou des
champs aux points de Gauss. evolution[i] rend la courbe i.
Une Evolution interpolée applique sa politique hors plage à toutes ses
courbes. Celle que rendent | et une tranche evolution[i:j] est "error" ;
chaque courbe garde la sienne, qui sert quand on l’interpole seule.
zone_a = pyrucast.SubEvolution([(0.0, 1.0), (1.0, 2.0)], out_of_range="clamp")
zone_b = pyrucast.SubEvolution([(0.0, 3.0), (1.0, 4.0)])
deux = zone_a | zone_b
print(deux.interpolate(0.5)) # [1.5, 3.5] : un flottant par courbe
print(deux.out_of_range) # error : l'agrégat applique la sienne à ses courbes
print(deux.interpolate(2.0, out_of_range="clamp")) # [2.0, 4.0]
Le long d’une ligne
pyrucast.evolution.along(field, line, components=None) (aussi
field.along(line)) rend les valeurs d’un champ aux nœuds le long d’une
ligne : une courbe par composante, la valeur en fonction de la distance
parcourue. line est un maillage POI1, lu dans l’ordre de ses mailles :
cet ordre est le trajet. L’abscisse d’un point est la longueur du trajet
jusqu’à lui — la somme des distances en ligne droite d’un point au suivant —,
0 au premier point ; son ordonnée, la valeur de la composante en ce nœud.
components=None prend toutes les composantes du champ, dans son ordre ;
chacune donne une courbe, dont l’ordinate_type est son nom.
Un champ d’un autre maillage, ou un champ aux points de Gauss, se porte
d’abord sur les points de la ligne par
pyrucast.node_field.project.
Deux points consécutifs confondus sont refusés : l’abscisse se répéterait.
# Une température relevée le long d'un trajet de trois points : la courbe porte
# la valeur en fonction de la distance parcourue — 0, 3, puis 3 + 4 = 7.
plan = pyrucast.Coords(2)
trajet = [plan.add_node(p) for p in ([0.0, 0.0], [3.0, 0.0], [3.0, 4.0])]
ligne = pyrucast.mesh.poi1_from_nodes(trajet)
releve = pyrucast.NodeField(ligne, ["T"])
for noeud, t in zip(trajet, [20.0, 50.0, 90.0]):
releve[0].set_value(noeud, "T", t)
le_long = pyrucast.evolution.along(releve, ligne) # aussi releve.along(ligne)
print(le_long[0].abscissas()) # [0.0, 3.0, 7.0]
Tracer
plot() dessine une évolution de nombres en courbe, une ligne par zone, et
une évolution de champs en champ sur le maillage, avec un curseur pour choisir
l’abscisse dans la fenêtre interactive (voir
Visualisation). Sans x_label ni
y_label, les axes et le curseur prennent l’abscissa_type et
l’ordinate_type. Une table ne se dessine pas en courbe.
courbe = pyrucast.Evolution(
[(0.0, 20.0), (10.0, 120.0)], abscissa_type="t", ordinate_type="T"
)
courbe.plot(save="courbe.svg") # axes étiquetés "t" et "T"
chauffe.plot(save="chauffe.png", frame=1) # le champ du pas tabulé n° 1
Sauvegarde
Une évolution se sauvegarde comme tout objet, avec sa politique hors plage et ses types. Les champs qu’elle tabule gardent leur support, partagé avec les autres objets du même fichier.
Côté Rust
SubEvolution porte :
abscissas, unVec<f64>plat :dimsnombres par échantillon, les échantillons triés dans l’ordre de leurs tuples (première variable, puis deuxième…) ;dims, le nombre de variables, de 1 àMAX_ABSCISSAS(4) ;values, unVec<SubValue>, une valeur par échantillon.SubValuevautScalar(f64),Node(SubNodeField)ouElement(SubElementField): les champs sont rangés dans la courbe, et leur support est unHandlepartagé ;out_of_range(OutOfRange),abscissa_type(Vec<String>) etordinate_type(Option<String>).
SubEvolution::new prend des échantillons dont l’abscisse implémente
Abscissa : un f64, un [f64; N] ou un Vec<f64>.
Evolution est l’agrégat de Handle<SubEvolution>, plus sa politique hors
plage. check_push refuse une courbe dont les valeurs ne sont pas du même
genre que celles des autres. Evolution::interpolate_at rend un
Interpolated : Scalars(Vec<f64>), Node(NodeField) ou
Element(ElementField). from_scalars, from_node_fields et
from_element_fields sont les constructeurs que Python appelle.
Interpolation. Une interrogation pèse au plus deux échantillons par
variable, donc au plus 16. gather les trouve par recherche dichotomique,
variable après variable, et range leurs poids dans un tableau de taille fixe,
sur la pile : les poids ne demandent aucune allocation. Pour des champs, la
somme pondérée reprend l’arithmétique des champs (map_all, puis
merge_components après check_same_components).
Appliquer à un champ. SubEvolution::interpolate_field et
Evolution::interpolate_node_field / interpolate_element_field résolvent
une fois, avant de parcourir le champ, quelles composantes chaque courbe lit
et quelle politique elle applique. Les lignes du champ sont ensuite traitées
en parallèle, sans allocation ; chaque ligne du résultat est écrite une fois,
depuis sa propre ligne d’entrée, si bien que le résultat ne dépend pas du
nombre de fils.
#[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(())
}