Makie

Makie jest bibioteką w pełni napisaną w Julii, co pozwala na pełną integrację i możliwości wykorzystania mechanizmów języka. W Makie mamy do dyspozycji kilka backendów, w zależności od planowanego sposobu użycia

  • CairoMakie - nieinteraktyne wykresy 2D o jakości potrzebnej do publikacji (grafika wektorowa)

  • GLMakie - interaktywne 2D i 3D wykresy, zoptymalizowane pod wydajność wyświetlania (czasem kosztem jakości) oparte o OpenGL

  • WGLMakie - oparte o WebGL, tworzy wykresy 2D i 3D do wyświetlania i interakcji w przeglądarkach

Zacznijmy od stworzenia prostego wykresu i omówimy jego składowe. Wyprodukujemy pseudo-pomiarowe dane z odrobiną losowego rozrzutu (udające rozpad promieniotwórczy) oraz będziemy udawać, że dopasowujemy do niego krzywą zaniku (w rzeczywistości znamy parametry rozkładu, więc nie musimy wołać żadnych funkcji dopasowujących, ale na wykresie ma to tak wyglądać).

Proste wykresy

using CairoMakie

fig = Figure()
ax = Axis(fig[1, 1])

x = 0:0.1:3
y = exp.(-x) .+ 0.05.*randn(size(x)[1])

scatter!(ax, x, y)
lines!(ax, x, exp.(-x))

current_figure()

Pierwszym obiektem jaki tworzymy to Figure, czyli okienko (ramka) w którym będziemy umieszczać kolejne elementy. Dane będziemy wykreślać w obiekcie Axis (osie). Składnia fig[1, 1] mówi, że umieszczamy go w pierwszym rzędzie i pierwszej kolumnie naszego okna (jak można się od razu domyśleć, można zatem mieć ich więcej). Wreszcie polecenia scatter! oraz lines! rysują nasze dane za pomocą linii. Funkcja current_figure zwraca bieżący wykres (w przypadku REPL nie będzie potrzebne, ale notatnik Jupytera go potrzebuje).

W przypadku tak prostego wykresu zasadniczo wszystkie te elementy mogą być stworzone domyślnie za pomocą wersji lines bez wykrzynika, który domyślnie tworzy i ramkę i osie.

scatter(x, y)
lines!(x, exp.(-x))
current_figure()

Tym niemniej rozbicie elementów wykresu na składowe pozwala zrozumieć w jaki sposób możemy go modyfikować, aby osiągnąć pożądany efekt oraz jak ewentualnie tworzyć znacznie bardziej skomplikowane wykresy.

Jeżeli chcemy uzyskać wykres o jakości potrzebnej do publikacji, to przede wszystkim wykres powinien mieć opisane osie, odpowiednio powiększoną czcionkę, ewentualnie usunięte pomocnicze linie, dobrane kolory, rozmiary punktów, posiadać legendę itd. Niektóre z tych wymogów należą do osi (np. rozmiar czcionki, linie pomocnicze), inne do serii danych (np. kolor).

Nasza oś ax posiada mnóstwo cech, które możemy zmieniać, po wpisania ax i naciśnięciu tabulatora powinny pojawić się podpowiedzi. Np. możemy wyłączyć linie pomocnicze w osi X. W ten sposób możemy krok po kroku pozmieniać pożądane elementy. Jeżeli z góry wiemy o jaki efekt nam chodzi, wszystko można zebrać w momencie tworzenia osi.

#ax. <TAB>, np.
ax.xgridvisible = false

Znaczenia większości parametrów użytych poniżej można domyśleć się po nazwie. Pewnego wyjaśnienia może wymagać limits, które w prezentowanej wersji przyjmuje argumenty (xmin, xmax, ymin, ymax), ale jeżeli któryś z parametrów jest nothing, to używana jest automatycznie dobierana wartość. limits ma kilka innych możliwości, które najlepiej sprawdzić w dokumentacji. O położeniu pomniejszych znaczników na osiach decyduje tu funkcja IntervalsBetween, która dzieli na zadaną liczbę równych odcinków.

fig = Figure()
ax = Axis(fig[1, 1]; 
        xlabel="Time (s)", 
        xlabelsize=28, 
        xticklabelsize=24, 
        xminorticks=IntervalsBetween(5), 
        xminorticksvisible=true, 
        xgridvisible=false,
        ylabel="Counts / 0.1 s", 
        ylabelsize=28, 
        yticklabelsize=24, 
        ygridvisible=false, 
        yminorticks=IntervalsBetween(5), 
        yminorticksvisible=true,
        limits=(0, nothing, 0, nothing)
       )

scatter!(ax, x, y, label="Exp.", marker=:rect, markersize=12)
lines!(ax, x, exp.(-x), label="Fit", color=:red, linestyle=:dash)
axislegend(ax; position=:rt, labelsize=24)

current_figure()

Interaktywne wykresy

W tabelce podsumowującej umiejętności bibliotek pochwaliłem GLMakie za wysoką interaktywność. Aby nie być gołosłownym, przykład wykresu, w którym możemy kontrolować parametry funkcji opisującej tłumiony oscylator harmoniczny. Analogiczne rozwiązania znają Państwo z programów typu Mathematica. Tutaj wkładamy dwa suwaki, do obiektu SliderGrid. Zwykły suwak nie ma etykiety i wyświetlanej bieżącej wartości, a ta wygodna funkcja pozwala umieścić kilka pasków i je od razu opisać.

Bardziej tajemnicza wydaje się zapewne sposób wprowadzania zmian. Zajmuje się tym funkcja lift, która reaguje na zmianę zmiennych typu Observales (a takimi są położenia suwaków). Składnia do jest wygodniejszym sposobem zapisu anonimowej funkcji (x, y) -> ..., którą podawalibyśmy do lift. Czyli inny sposób zapisu wyglądałby tak:

y = lift((ω, γ)-> exp.(-γ .* t) .* cos.(sqrt(ω^2 + γ^2) .* t), sg.sliders[1].value, sg.sliders[2].value)

co przy bardziej skomplikowanej funkcji byłoby jeszcze bardziej nieczytelne.

DataInspector() uruchamia interaktywny podgląd danych po najechaniu myszką na wybrany punkt.

Uwaga! Przykład nie będzie działał interaktywnie w notatniku Jupyter.

using GLMakie

GLMakie.activate!()

fig = Figure()
ax = Axis(fig[1, 1], limits=(0, 20, -1.1, 1.1), ylabel=L"y (t)", xlabel=L"t")

t = 0:0.1:20

sg = SliderGrid(
        fig[2, 1],
        (label="ω", range=0.1:0.1:3, format="{:.1f}", startvalue=2.0),
        (label="γ", range=0.0:0.1:3, format="{:.1f}", startvalue=0.1)
       )

y = lift(sg.sliders[1].value, sg.sliders[2].value) do ω, γ
    Ω = sqrt(ω^2 + γ^2)
    exp.(-γ .* t) .* cos.(Ω .* t)
end

lines!(ax, t, y)
DataInspector()

fig

Zapisywanie wykresów do pliku

save(plik, fig) zapisuje bieżący wykres do pliku. Rozszerzenie definiuje typ pliku, GLMakie tworzy tylko .png, natomiast CairoMakie ma do wyboru .png, .svg i .pdf.

Skomplikowane układy

Spróbujemy zrobić teraz nieco bardziej skomplikowany wykres. Załóżmy, że mamy dane, które pochodzą z rozkładu binormalnego (dwuwymiarowego normalnego) z pewnymi wartościami średnimi i dyspersjami w X i Y oraz współczynnikiem korelacji. Chcemy narysować dwuwymiarową mapę wylosowanych wartości oraz odpowiedni rzuty na osie X i Y w osobnych wykresach.

Problem losowania i stworzenia histogramu rozwiążemy za pomocą bibliotek Distributions oraz StatsBase, gdzie znajdziemy odpowiednie funkcje.

using Distributions
using StatsBase
using LinearAlgebra

μx = -0.5
σx = 1.1
μy = 0.5
σy = 1.5
ρ = 0.8
mvn = MvNormal([μx, μy], [σx^2 ρ*σx*σy;  ρ*σx*σy σy^2])
x = rand(mvn, 200_000)
h = fit(Histogram, (x[1, :], x[2, :]), (-5:0.1:5.1, -5:0.1:5.1))
h = normalize(h)

Pierwsze podejście do problemu wygląda tak: w położeniu [1, 1] rysujemy mapę (countourf - mapa poziomic z wypełnionymi konturami), w położeniu [1, 2], narysujemy rzut na oś Y (pierwszy wymiar w macierzy), a [2, 1] - rzut na oś X (drugi wymiar macierzy).

using CairoMakie
CairoMakie.activate!()

fig = Figure(size = (800, 800))

axc = Axis(fig[1, 1]; xlabel="X", ylabel="Y")
axb = Axis(fig[2, 1]; xlabel="X", ylabel="Prob. density")
axr = Axis(fig[1, 2]; xlabel="Prob. density", ylabel="Y")

contourf!(axc, h.edges[1][1:end-1], h.edges[2][1:end-1], h.weights)
stairs!(axr, vec(sum(h.weights, dims=1)), collect(h.edges[1][1:end-1]))
stairs!(axb, collect(h.edges[2][1:end-1]), vec(sum(h.weights, dims=2)))

current_figure()

Rysunek jest w miarę w porządku, ale wymaga teraz trochę dopieszczenia.

  1. Mapa może być większa, a rzuty odpowiednio mniejsze w osiach zliczeń

  2. Oś Y w rzucie Y, mogłaby być z prawej, zamiast lewej strony

  3. Skale powinny zaczynać się w tym samym miejscu

  4. Znaczniki mogłyby być lepiej rozmieszczone

  5. Nie chcemy pomocniczych linii

  6. Chcemy większe czcionki

  7. Przydałby się informacja o skali kolorowej, najlepiej na górze, aby nie zaburzać położeń rzutów

fig = Figure(size = (800, 800))

gc = GridLayout()
fig[1, 1] = gc
axc = Axis(gc[2, 1])

axb = Axis(fig[2, 1]; xlabel=L"$X$", ylabel="Prob. density",
        limits=(-5, 5, 0, 4),
        xlabelsize=28, 
        xticklabelsize=24, 
        xminorticks=IntervalsBetween(5), 
        xminorticksvisible=true, 
        xgridvisible=false,
        ylabelsize=28, 
        yticklabelsize=24, 
        yminorticks=IntervalsBetween(5), 
        yminorticksvisible=true, 
        ygridvisible=false,
)

gr = GridLayout()
fig[1, 2] = gr
axr = Axis(gr[1, 1]; xlabel="Prob. density", ylabel=L"$Y$",
        limits=(0, 4, -5, 5),
        xaxisposition=:top,
        xlabelsize=28, 
        xticklabelsize=24, 
        xminorticks=IntervalsBetween(5), 
        xminorticksvisible=true, 
        xgridvisible=false,
        yaxisposition=:right,
        ylabelsize=28, 
        yticklabelsize=24, 
        ylabelrotation=0,
        yminorticks=IntervalsBetween(5), 
        yminorticksvisible=true,
        ygridvisible=false,
    
    )

hidedecorations!(axc)
linkxaxes!(axc, axb)
linkyaxes!(axc, axr)

gr.alignmode = Mixed(top=-38)
colsize!(fig.layout, 1, Relative(2/3))
rowsize!(fig.layout, 1, Relative(2/3))

cf = contourf!(axc, h.edges[1][1:end-1], h.edges[2][1:end-1], h.weights)
text!(axc, [-4.8], [3.8], text="a)", color=:white, fontsize=28)
cb = Colorbar(gc[1, 1], cf, ticklabelsize=24, vertical=false)

stairs!(axr, vec(sum(h.weights, dims=1)), collect(h.edges[1][1:end-1]))
text!(axr, [3.5], [3.8], text="b)", fontsize=28, align=(:right, :bottom))

stairs!(axb, collect(h.edges[2][1:end-1]), vec(sum(h.weights, dims=2)))
text!(axb, [-4.8], [3], text="c)", fontsize=28)

resize_to_layout!(fig)
current_figure()

Uff! Ale co tu się dokładnie stało? Popatrzmy na listę żądań i sprawdzimy krok po kroku jak zostały spełnione.

  1. Mapa może być większa, a rzuty odpowiednio mniejsze w osiach zliczeń

Kolejne wykresy wkładane są w oczka siatki (fig[1, 1], fig[1, 2], itd.). W poprzedniej wersji każdy wykres miał do dyspozycji tyle samo miejsca. Tu zmieniamy propocje kolumn i wierszy, aby kolorowa mapa była największa. Tak naprawdę dzieje się to za pomocą:

colsize!(fig.layout, 1, Relative(2/3))
rowsize!(fig.layout, 1, Relative(2/3))
  1. Przydałby się informacja o skali kolorowej, najlepiej na górze, aby nie zaburzać położeń rzutów

To żądanie trochę nabroiło. Otóż siatki w których umieszczane są elementy wykresu mogą być zagnieżdżone. Wykorzystaliśmy to do umieszczenia zamiast mapy w polu [1, 1], nowej siatki gc = GridLayout(), która została podzielona na gc[1, 1] zawierające pasek kolorowy i gc[2, 1] zawierający mapę.

To niestety ma swoje konsekwencje jeżeli chodzi o ustawienie względem innych wykresów. Wykres z prawej strony byłby teraz wyrównany względem paska i jego górna krawędź nie pasowałaby do mapy. Dlatego jego polu fig[1, 2] zostało również nadana siatka gr. Co ciekawe zawiera tylko jedno pole gr[1, 1], ale dzięki temu możemy niezależnie zmienić jego parametry dotyczące wyrównania. I tak

gr.alignmode = Mixed(top=-38)

Oznacza, że tryb wyrównywania jest mieszany (domyślny tryb to Auto lub Fixed, Mixed ma niektóre krawędzie Auto, inne Fixed). Wartość jest dobrana tak, aby górna krawędź wykresu (liczy się razem z etykietą!) pasowała do mapy.

  1. Skale powinny zaczynać się w tym samym miejscu

Tu oprócz użycia limits = ... mamy też

hidedecorations!(axc)
linkxaxes!(axc, axb)
linkyaxes!(axc, axr)

Pierwsze polecenie usuwa wszelkie dekoracje (znaczniki, etykiety) z wykresu mapy (są też odpowiednio hidexdecorations!, hideydecorations!), które nie będą potrzebne, bo osie opiszemy na rzutach. Następnie osie X mapy i dolnego wykresu są ze sobą powiązane i podobnie osie Y mapy i prawego wykresu. Ewentualna zmiana skali powiązanych osi zmienia je automatycznie w pozostałych.

  1. Oś Y w rzucie Y, mogłaby być z prawej, zamiast lewej strony

Ten problem (i przy okazji przeniesienie osi X na górę) załatwia nam

xaxisposition=:top,
    yaxisposition=:right,
  1. Znaczniki mogłyby być lepiej rozmieszczone

Po zmianie granic na -5,5 naturalne było wprowadzenie 5 mniejszych znaczników (x/yminorticks=IntervalsBetween(5)).

  1. Nie chcemy pomocniczych linii

Tak jak wcześniej x/ygridvisible=false

  1. Chcemy większe czcionki

To już również było ylabelsize=28, yticklabelsize=24, itd. Przy okazji pojawiły się oznaczenia podwykresów a, b, c. Do tego oczywiście służą polecenia

text!(axc, [-4.8], [3.8], text="a)", color=:white, fontsize=28)

Zadanie

Odtworzyć poniższy wykres przedstawiający w dwuwymiarowym rzucie fukcję falową stanu 1s i 2p (0 i ±\pm1) atomu wodoru w górnych panelach. Dolne prezentują przekrój przez gęstość prawdopodobieństwa (czyli ψ2\left|\psi\right|^2) wzdłuż osi x lub y dla funkcji falowych. Wzory na funkcje falowe można znaleźć tutaj. Skala jest w Angstromach (101110^{-11} m), wartość promieniu Bohra można wpisać jako stałą.

"hydrogen_makie"

Wskazówka. Wielomian Hermite'a stopnia można n można dostać z modułu SpecialPolynomials (poniżej przykład).

using SpecialPolynomials

H2 = basis(Hermite, 2)
H3 = basis(Hermite, 3)

x = -2:0.01:2
lines(x, H2.(x), label="n=2")
lines!(x, H3.(x), label="n=3")
axislegend()

current_figure()
CC BY-SA 4.0 Krzysztof Miernik. Last modified: November 22, 2024. Website built with Franklin.jl and the Julia programming language.