Graphiques animés avec Makie.jl

Nous reprenons le modèle de Lotka Volterra, et testons les capacités de création de graphiques animés avec Makie.jl1. Ce document est largement inspiré par l’excellent tutoriel sur le double pendule chaotique par G. Datseris.

1 Il est aussi possible de créér sur le même modèle des graphiques interactifs

Trajectoire animée en 2D

Observables

Le principe tire partie des conteneurs Observable, qui sont des conteneurs mutables que l’on peut donc modifier et dont on peut surveiller l’évènement de modification. Lorsqu’un Observable passé à Makie.jl est modifié, ce dernier le prend en compte et met à jour le graphique correspondant automatiquement. Cela fonctionne particulièrement bien avec le backend GL, qui depuis un script ou un notebook ouvre une fenetre graphique qui se met à jour automatiquement. Sur cette page, il nous faut réafficher la figure.

Prenons un exemple, sur une simple figure, on définit un Observable random puis on le trace:

using GLMakie

x = 1:4
y = Observable(rand(4))
fig, ax = lines(x, y)

Ensuite on réassigne la valeur de l’observable et on réaffiche la figure qui s’est mise à jour automatiquement, sans retracer la line.

Note

On utilise la syntaxe y[]= qui assigne le contenu de l’observable et informe le système de la mise à jour de l’observable.

y[] = rand(4)
fig

Il s’agit d’exploiter ce principe pour créér un graphique animé.

Trajectoire de Lotka Volterra

Nous allons représenter la trajectoire au cours du temps comme un point mobile dans l’espace d’état, avec une “queue” qui représente les valeurs de l’état dans le passé proche, comme ceci:

On commence par définir les fonctions et paramètres pour simuler le modèle et créer un problème ODE prob_lv.

Code
using DifferentialEquations

# conditions initiales
x0 = 1.0
y0 = 1.95
etat0 = [x0, y0]

# paramètres
r = 1.0
c = 1.0
b = 1.0
m = 1.0
par_lovo = [r, c, b, m]

# temps
tspan = (0.0, 30.0)
tstep = .01

# définition du modèle
function lovo(u, par, t)
    r, c, b, m = par
    x, y = u
    dx = r*x - c*x*y
    dy = b*x*y - m*y
    return [dx, dy]
end

# define ODE problem
prob_lv = ODEProblem(lovo, etat0, tspan, par_lovo)

Nous créons des observables pour l’état et pour la queue de simulation sous la forme d’objets de type Point2f (ou CircularBuffer de Point2f) pour les passer à Makie.

Note
  • les objets de type Point2f sont la structure la plus efficace pour tracer des points en 2D pour Makie
  • un objet CircularBuffer est un vecteur de taille fixe qu’on remplit par la fin via push!() et qui se vide automatiquement par le début pour garder sa taille
# observable pour l'état
x, y = etat0
state_lv = Observable(Point2f(x, y))     # initialisation

# observable pour la queue
using DataStructures: CircularBuffer

tailsize = 600
tail = CircularBuffer{Point2f}(tailsize)    # une queue de simulation
fill!(tail, Point2f(x, y))      # que l'on initialize sur la condition initiale
tail = Observable(tail)         # et que l'on transforme en Observable

L’animation repose sur une simulation de proche en proche pour pouvoir créer l’animation. Pour cela nous utilisons l’interface integrator de DifferentialEquations.jl et la fonction step!(integrator) qui calcule la solution au bout d’un pas de temps (en place). Nous créons une fonction qui effectue ce calcul et met à jour les Observables état (state_lv) et queue (tail) (en place !).

integrator_lv = init(prob_lv, Tsit5())    # interface integrator

function step_lv!(integrator, state_lv, tail)
    # calcule la solution a t+0.01, en place
    step!(integrator, 0.01,  true)
    # assigne la solution à x et y
    x, y = integrator.u

    # met à jout les
    state_lv[] = Point2f(x, y)        # met à jour l'Observable
    push!(tail[], Point2f(x,y))      # assigne la nouvelle valeur dans la queue, en place
    tail[] = tail[]                  # le push en place n'indique pas la mise à jour de l'Observable
end

Il faut maintenant définir la figure en elle-même: on trace une position de l’état et de la queue (à ce stade sur la condition initiale):

# Création de Figure, Axis
fig = Figure()
ax = Axis(fig[1, 1]; xticks = 0:0.5:2, yticks = 0:0.5:2)

# champs de vecteur
scale = 10
xrange = range(0, 2.75, length=11)
yrange = range(0, 2.75, length=11)

# calcule des dérivées sur la grille (xrange, yrange)
derx = [lovo([x y], par_lovo, 0)[1]/scale for x in xrange, y in yrange]
dery = [lovo([x y], par_lovo, 0)[2]/scale for x in xrange, y in yrange]

# champs de vecteur
arrows!(
    ax,
    xrange,
    yrange,
    derx,
    dery;
    color = :lightgray,
    arrowsize = 10,
)

# positive equilibrium
scatter!(ax, m/b, r/c; marker = :star, color = :grey, markersize = 14)

# plot of the state
scatter!(
    ax,
    state_lv;
    marker = :circle,
    strokewidth = 2,
    strokecolor = :purple,
    color = :black,
    markersize = 8,
)

# plot of the tail
# echelle de couleur pour la queue: 100% transparent au purple via parametre alpha
col = to_color(:purple)
tailcol = [RGBAf(col.r, col.g, col.b, (i/tailsize)^2) for i in 1:tailsize]
lines!(ax, tail; linewidth = 3, color = tailcol)

# enluminures
ax.title = "Lotka Volterra"
ax.xlabel = "Proies"
ax.ylabel = "Prédateurs"
xlims!(ax, 0, 2.25)
ylims!(ax, 0, 2.25)

fig

Puis on intègre de proche en proche via stepl_lv! :

# test the 2D plot
for in in 1:1000
    step_lv!(integrator_lv, state_lv, tail)
    sleep(.001)
end

Depuis un script ou un notebook, la figure proposée par GLMakie devrait s’animer. Sur cette page html, nous ne pouvons qu’afficher la dernière simulation :

fig

Image animée pour site

Il faut en fait générer une image animée (typiquement .gif) pour pouvoir visualiser l’animation sur cette page.

On peut commencer par réunir tout le code d’initialisation et de génération de figure dans une fonction, pour facilement réinitialiser:

function init_anim_lv(etat0, params)
    # Odeproblem, integrator
    prob_lv = ODEProblem(lovo, etat0, tspan, params)
    integrator_lv = init(prob_lv, Tsit5())

    # condition initiale, observables etat et queue
    x, y = etat0
    state_lv = Observable(Point2f(x, y))
    tailsize = 600
    tail = CircularBuffer{Point2f}(tailsize)
    fill!(tail, Point2f(x, y))
    tail = Observable(tail)

    # figure
    fig = Figure()
    ax = Axis(fig[1, 1]; xticks = 0:0.5:2, yticks = 0:0.5:2)
    scale = 10

    xrange = range(0, 2.75, length=11)
    yrange = range(0, 2.75, length=11)
    derx = [lovo([x y], params, 0)[1]/scale for x in xrange, y in yrange]
    dery = [lovo([x y], params, 0)[2]/scale for x in xrange, y in yrange]

    arrows!(
        ax,
        xrange,
        yrange,
        derx,
        dery;
        color = :lightgray,
        arrowsize = 10,
    )

    r, c, b, m = params
    scatter!(ax, m/b, r/c; marker = :star, color = :grey, markersize = 14)

    scatter!(
        ax,
        state_lv;
        marker = :circle,
        strokewidth = 2,
        strokecolor = :purple,
        color = :black,
        markersize = 8,
    )

    col = to_color(:purple)
    tailcol = [RGBAf(col.r, col.g, col.b, (i/tailsize)^2) for i in 1:tailsize]
    lines!(ax, tail, linewidth = 3, color = tailcol)

    ax.title = "Lotka Volterra"
    ax.xlabel = "Proies"
    ax.ylabel = "Prédateurs"
    xlims!(ax, 0, 2.25)
    ylims!(ax, 0, 2.25)

    return fig, integrator_lv, state_lv, tail
end

Nous générons la figure animée sous forme d’un gif en utilisant record :

# on crée la figure
fig, integrator_lv, state_lv, tail = init_anim_lv(etat0, par_lovo)

# on veut 132 images pour l'animation
frames = 1:132
# record enregistre la figure fig à chaque fois en itérant frames
## do i ... end passe une fonction anonyme en premier argument à record() qui crée les images
## la boucle for sert juste à renvoyer l'image tous les 5 steps
record(fig, "lv.gif", frames; framerate = 60) do i
    for j in 1:5
        step_lv!(integrator_lv, state_lv, tail)
    end
end

Et finalement nous pouvons afficher la figure animée:

Trajectoire animée en 3D

Au prix d’une modification très minime du code ci-dessus, on peut facilement créér une animation de la trajectoire en 3 dimensions (x, y, H(x,y)). Il faut essentiellement remplacer le système d’axe 2D par un système d’axe 3D Axis3 et les objets Point2f par des Point3f.

Code
# on définit l'intégrale première
function int_prem(x, y, par = par_lovo)
    r, c, b, m = par
    H = -r*log(y) + c*y - m*log(x) + b*x
    return H
end

function init_anim3d_lv(etat0, params)
    # Odeproblem, integrator
    prob_lv = ODEProblem(lovo, etat0, tspan, params)
    integrator_lv = init(prob_lv, Tsit5())

    x, y = etat0
    H0 = int_prem(x, y)
    state3d_lv = Observable(Point3f(x, y, H0))
    tailsize = 1000
    tail3 = CircularBuffer{Point3f}(tailsize)
    fill!(tail3, Point3f(x, y, H0))
    tail3 = Observable(tail3)

    fig = Figure()
    ax = Axis3(
        fig[1, 1];
        azimuth = 0.5,
        elevation = 0.2,
        xticks = 0:0.5:2,
        yticks = 0:0.5:2,
    )

    scatter!(
        ax,
        state3d_lv;
        marker = :circle,
        strokewidth = 2,
        strokecolor = :purple,
        color = :black,
        markersize = 8,
    )

    col = to_color(:purple)
    tailcol = [RGBAf(col.r, col.g, col.b, (i/tailsize)^2) for i in 1:tailsize]
    lines!(ax, tail3; linewidth = 3, color = tailcol)

    xsurf = 0.25:0.1:2.25
    ysurf = 0.25:0.1:2.25
    # calcul de la surface via une compréhension de liste
    hsurf = [int_prem(x, y, params) for x in xsurf, y in ysurf]
    # tracé de H(x,y) et du plan z = H(x0,y0)
    hs = surface!(ax, xsurf, ysurf, hsurf; alpha = 0.2)

    ax.title = "Lotka Volterra"
    ax.xlabel = "Proies"
    ax.ylabel = "Prédateurs"
    ax.zlabel = L"$H(x,y)$"
    xlims!(ax, 0, 2.25)
    ylims!(ax, 0, 2.25)
    zlims!(ax, 1.7, 3.5)

    return fig, integrator_lv, state3d_lv, tail3
end

On adapte la fonction d’animation qui avance d’un pas de temps à la nouvelle structure des observables en 3D.

function animsteplv3!(integrator_lv, state3d_lv, tail3)
    step!(integrator_lv, 0.01,  true)
    x, y = integrator_lv.u
    # comme H(x,y) ne varie pas, on ne change pas sa valeur
    state3d_lv[] = Point3f(x, y, state3d_lv[][3])
    push!(tail3[], Point3f(x, y, state3d_lv[][3]))
    tail3[] = tail3[]
end

Et on génère une figure gif pour afficher sur cette page.

#! output: true
fig, integrator_lv, state3d_lv, tail3 = init_anim3d_lv(etat0, par_lovo)

frames = 1:132
record(fig, "lv3d.gif", frames; framerate = 60) do i
    for j in 1:5
        animsteplv3!(integrator_lv, state3d_lv, tail3)
    end
end

Finalement: