Przykład pracy nad nowym modułem
Jako ilustrację pracy w Julii projektu pod postacią modułu spróbujemy stworzyć moduł zajmujący się masami jądrowymi. Kompilacja danych eksperymentalnych ukazuje się cyklicznie jako Atomic Mass Evaluation. Ostatnia edycja z 2020 roku jest dostępna tutaj, a dane w postaci tekstowej są np. tu. W tablicach podawany jest bardzo wygodny opis mas nuklidów (nuklid = neutralny atom) przez mass excess
(deficyt masy, defekt masy). Wynika to z faktu, że liczba masowa nuklidu z bardzo dobrym przybliżeniem opisuje jego masę, i potrzebna jest tylko drobna poprawka:
Jednostka masy zdefiniowana jest arbitralnie jako , a więc deficyt masy nie opisuje w bezpośredni sposób intuicyjnych wielkości fizycznej. jest równe 931.494 MeV/c, a deficyty mas do kilkudziesięciu MeV/c.
Polskie nazewnictwo jest w tym przypadku wyjątkowo niespójne i w większości miejsc pojęcia deficytu (defektu) masy są utożsamiane z różnicą między masą jądra, a sumą mas składników, ale ta wielkość jest po prostu energią wiązania. Po co zatem stosować trzy nazwy na energię wiązania, a żadnej na wielkość ? Niestety za wikipedią ten błąd jest powtarzany tak powszechnie, że trudno jest znaleźć w internecie źródło twierdzące inaczej.
Korzystanie z tych tabel (format pozycyjny) jest mało wygodne. Wprawdzie dość łatwo jest dane wczytać i przedstawić w postaci np. tablicy DataFrame, ale za każdym razem, gdy potrzebna jest nam informacja o danym nuklidzie, musielibyśmy wyszukiwać odpowiedni wiersz tabeli (df[(df.A .== A) .& (df.Z .== Z), :]]
). Często zamiast liczb atomowych używane są nazwy pierwiastków lub symbole chemiczne, np "U-238", a nie każdy pamięta wszystkie liczby atomowe. Musielibyśmy napisać zatem wygodne funkcje wyszukujące po nazwie lub A, Z. Moglibyśmy też pokusić się, dla wygody, o zdefiniowanie funkcji łączącej dwa jądra. Pomysłów można mieć pewnie i więcej... to może od razu lepiej zrobić moduł?
Założenia
wczytujemy dane w oryginalnej postaci do jakiejś tabeli (jakiej?)
dla konkretnego nuklidu mamy strukturę zawierającą interesujące nas informacje (A, Z, delta)
funkcje szukające w danych odpowiedniego jądra i zwracające je w postaci struktury
operator dodawania
Zaczynamy od stworzenia modułu poprzez wzorce PkgTemplates
using PkgTemplates
t = Template(;user="kmiernik", dir="~/workspace/Julia")
t("MassExcess")
Uwaga!
Od tej pory proponuję przejść do katalogu i używać ulubionego edytora. Fragmenty kodu pojawiające się poniżej służą tylko ilustracji, a nie uruchamianiu w Jupyterze!
Zaczniemy od utworzenia odpowiedniej struktury opisującej nuklid. W pliku MassExcess.jl:
module MassExcess
"""
Structure holding information on nuclide:
A, Z, element name, mass excess `me`, uncertainty (`dme`)
and flag if it is an experimental value (`exp`)
"""
struct Nuclide
A::Int64
Z::Int64
element::String
me::Float64
dme::Float64
end
end
Skoro mamy już naszą strukturę, możemy zacząć pisać testy, testujące nasze wymagania. W pliku test/runtest.jl przetestujemy różne postaci konstruktorów naszej struktury:
Nuclide(A, Z)
Nuclide("symbol", A)
Nuclide("symbol-A")
gdzie "symbol"
oznacza chemiczny symbol pierwiastka (np. Au, Pb, He), zapisany w dowolnej kombinacji dużych i małych liter (np. He, HE, he). Z tablic odczytamy interesujące nas wartości i stworzymy bezpośrednio nuklid, a następnie zażądamy, ale konstruktory zwracały dokładnie ten sam obiekt.
@testset "MassExcess.jl" begin
he4 = Nuclide(4, 2, "He", 2424.91587, 0.00015)
@test Nuclide("He-4") == he4
@test Nuclide("HE-4") == he4
@test Nuclide("he-4") == he4
end
Testowanie zaczniemy od razu. Uruchamiamy julię w katalogu głównym projektu. W REPL-u włączymy moduł Revise
, który śledzi na bieżąco zmiany, uruchomimy środowisko pakietu i test
julia> using Revise
(@v1.8) pkg> activate .
(MassExcess) pkg> test
Dostajemy
UndefVarError: Nuclide not defined
(...)
@ ~/workspace/Julia/MassExcess/test/runtests.jl:6
(...)
Test Summary: | Error Total Time
MassExcess.jl | 1 1 1.1s
ERROR: LoadError: Some tests did not pass: 0 passed, 0 failed, 1 errored, 0 broken.
in expression starting at /home/krm/workspace/Julia/MassExcess/test/runtests.jl:4
ERROR: Package MassExcess errored during testing
Oczywiście zapominieliśmy wyeksportować strukturę Nuclide
. Dodajemy export Nuclide
i uruchamiamy test jeszcze raz.
(...)
Test Summary: | Error Total Time
MassExcess.jl | 4 4 2.0s
(...)
Żaden test nie przeszedł, bo oczywiście nie mamy jeszcze odpowiednich konstruktorów. Warto zwrócić uwagę, że w poprzedniej próbie był tylko jeden test (@testset
doszedł do momentu pierwszego błędu nie objętego testem jednostkowym), natomiast fakt wystąpienia błędu w teście jednostkowym @test
nie powoduje przerwania zestawu testów.
Uzupełnianiamy moduł o brakujące informacje. Zaczniemy od nazw pierwiastków, zakodujemy je w postaci tablicy
# in MassExcess
elements = ("H" , "He", "Li", "Be", "B" ,
"C" , "N", "O", "F", "Ne",
"Na", "Mg", "Al", "Si", "P",
"S" , "Cl", "Ar", "K", "Ca",
"Sc", "Ti", "V", "Cr", "Mn",
"Fe", "Co", "Ni", "Cu", "Zn",
"Ga", "Ge", "As", "Se", "Br",
"Kr", "Rb", "Sr", "Y", "Zr",
"Nb", "Mo", "Tc", "Ru", "Rh",
"Pd", "Ag", "Cd", "In", "Sn",
"Sb", "Te", "I", "Xe", "Cs",
"Ba", "La", "Ce", "Pr", "Nd",
"Pm", "Sm", "Eu", "Gd", "Tb",
"Dy", "Ho", "Er", "Tm", "Yb",
"Lu", "Hf", "Ta", "W", "Re",
"Os", "Ir", "Pt", "Au", "Hg",
"Tl", "Pb", "Bi", "Po", "At",
"Rn", "Fr", "Ra", "Ac", "Th",
"Pa", "U", "Np", "Pu", "Am",
"Cm", "Bk", "Cf", "Es", "Fm",
"Md", "No", "Lr", "Rf", "Db",
"Sg", "Bh", "Hs", "Mt", "Ds",
"Rg", "Cn", "Nh", "Fl", "Mc",
"Lv", "Ts", "Og")
Teraz pierwiastek o liczbie atomowej Z
będzie zakodowany w Z-tym elemencie tablicy. Następnie brakuje nam tablicy z danymi. Zapiszemy plik w katalogu data/ame2020.dat
i dopiszemy funkcję ładującą tablicę. Aby odpocząć nieco od DataFrames tym razem wczytamy go do struktury NamedTuple, gdzie każdy (nazwany) element będzie wektorem odpowiednich danych:
(Z=[0, 1, 1, 1, 2, ...], A = [1, 1, 2, 3, 3, ...],
me = [8071.31806, 7288.971064, 13135.722895, 14949.81090, 14931.21888, ...], ...)
czyli dane z i-tego wiersza tabeli będą zawarte w i-tych elementach odpowiednich wektorów.
Pewną komplikacją jest fakt, iż niektóre dane z tabeli nie są wynikami eksperymentalnymi, a ekstrapolacjami danych. Są one zaznaczone znakiem "#". Ponieważ interesują nas dane ściśle eksperymentalne, te punkty pominiemy przy ładowaniu.
W tabeli informacje o Z jądra znajdziemy w kolumnach 10-14, liczbie masowej A - 15-19, defekcie masy - 29-42, a niepewności w 44-54.
function parseame20(amefile)
skipto = 37
iline = 0
Zs = Int64[]
As = Int64[]
ms = Float64[]
ds = Float64[]
for line in readlines(amefile)
iline += 1
if iline < skipto
continue
end
d = line[29:42]
if occursin("#", d)
continue
end
push!(Zs, parse(Int64, line[10:14]))
push!(As, parse(Int64, line[15:19]))
push!(ms, parse(Float64, d))
push!(ds, parse(Float64, line[44:54]))
end
(Z=Zs, A=As, me=ms, dme=ds)
end
Dane chcemy wczytać teraz przy ładowaniu struktury
data = parseame20(pkgdir(MassExcess, "data", "ame2020.dat"))
Funkcja pkgdir(module, "path", ...)
zwraca ścieżkę w której zainstalowany jest w danym systemie moduł oraz względne ścieżki do katalogów lub plików.
Nie mamy jeszcze odpowiednich konstruktorów, ale możemy dopisać test wczytywania danych. Pierwszym jądrem powinien być neutron (Z=0, A=1, me=8071.31806), a ostatnim bez znaczka ekstrapolacji Ds-270 (Z=110, me=134681.584). Dopiszemy to do testów
@test MassExcess.data.A[1] == 1
@test MassExcess.data.Z[1] == 0
@test MassExcess.data.me[1] == 8071.31806
@test MassExcess.data.A[end] == 270
@test MassExcess.data.Z[end] == 110
@test MassExcess.data.me[end] == 134681.584
Test Summary: | Pass Error Total Time
MassExcess.jl | 6 4 10 1.9s
ERROR: LoadError: Some tests did not pass: 6 passed, 0 failed, 4 errored, 0 broken
Nowe testy (6) przechodzi, 4 błędne to konstruktory, którymi najwyższy czas się zająć. Zaczniemy od postaci Nuclide(A, Z). Musimy znaleźć wiersz, w którym mamy jądro A, Z; jeśli go nie znajdziemy to nadamy mu wartości defektu Inf
. Wiersz znajdziemy poleceniem findfirst()
function Nuclide(A::Int64, Z::Int64)
id = findfirst(i->data.A[i] == A && data.Z[i] == Z, eachindex(data.A))
if isnothing(id)
Nuclide(A, Z, elements[Z], Inf, Inf)
else
Nuclide(A, Z, elements[Z], data.me[id[1]], data.dme[id[1]])
end
end
Wszystko jest dobrze, zaliczamy dodatkowy test, ale co jeśli ktoś stworzy jądro Nuclide(208, 300), albo Nuclide(300, 128), albo Nuclide(-1, -1)? Niektóre z nich teoretycznie mają sens (choć nie są znane - (300, 128)), inne w ogóle są bez sensu (Z > A, Z < 0). Wypadałoby odpowiednio potraktować te przypadki. Na przykład nieznanym (jeszcze?) pierwiastkom możemy nadać nazwę zgodną z liczbą Z (np. "120"), a wyrzucić odpowiedni błąd dla złych danych wejściowych.
Ubierzemy to w formę konstruktora (A, Z, me, dme), z którego będą mogły korzystać inne konstruktory. Poprawiamy nasz konstruktor (A, Z).
function Nuclide(A, Z, me, dme)
@assert A >= Z
if 1 <= Z <= length(elements)
name = elements[Z]
elseif Z == 0
name = "n"
elseif Z > length(elements)
name = "$Z"
else
throw(ArgumentError("Element Z = $Z is not valid"))
end
Nuclide(A, Z, name, me, dme)
end
function Nuclide(A::Int64, Z::Int64)
id = findfirst(i->data.A[i] == A && data.Z[i] == Z, eachindex(data.A))
if isnothing(id)
Nuclide(A, Z, Inf, Inf)
else
Nuclide(A, Z, data.me[id[1]], data.dme[id[1]])
end
end
Zostały nam jeszcze dwa konstruktory, które przerabiają symbol pierwiastka chemicznego na liczbę Z. Zaczniemy od wersji Nuclide("symbol", A). Potrzebna nam będzie funkcja wyszukująca numer pierwiastka, przy czym musimy pamiętać, że chcieliśmy wersje ("HE", "He", "he"), a tabela zawiera pierwszą literę zawsze wielką.
function getZ(name::String)
frmname = uppercase(name[1]) * lowercase(name[2:end])
@assert !isnothing(findfirst(x->x==frmname, elements)) "Element name $name is not known"
findfirst(x->x==frmname, elements)
end
Konstruktor teraz zmieści się w jednej linijce
Nuclide(A::Int64, element) = Nuclide(A, getZ(element))
Ostatni konstruktor będzie akceptował ciągi w postaci "symbol-A", np. "He-4", "HE-4" lub "he-4". Podzielimy zatem podany ciąg znaków w miejscu myślnika i spróbujemy wywołać poprzednią wersję. Drobna uwaga - split zwraca podciąg (SubString) czyli jedynie widok (view) oryginalnego ciągu (dzięki temu nie tworzy kopii).
function Nuclide(name::String)
@assert length(split(name, "-")) == 2 "Nuclide name $name format is not supported"
e, A = split(name, "-")
A = parse(Int64, A)
Nuclide(A, String(e))
end
Ostatnim elementem, o który na razie prosiliśmy jest operator dodawania. Stworzymy najpierw test, w którym trzykrotnie dodamy He-4 i powinniśmy dostać C-12, które jest wyjątkowe, bo ma defekt masy z definicji równy zero.
c12 = he4 + he4 + he4
@test c12.A == 12
@test c12.Z == 6
@test c12.me == 0.0
Aby zdefiniować operator +
musimy stworzyć nową metodę funkcji Base.+
. W naszym przypadku, jeżeli ktoś poda dwa nuklidy, dostanie trzeci, który stworzymy jako sumę A i Z poszczególnych składników.
Base.:+(x::Nuclide, y::Nuclide) = Nuclide(x.A + y.A, x.Z + y.Z)
Jako ostatni element możemy jeszcze stworzyć funkcję definującą w jaki sposób ma być wypisywana nasz obiekt Nuclide. Np. może to być Symbol-A (C-12)
Dodawanie modułu do Julii
Nasz moduł jest gotowy. Możemy zapisać teraz zmiany w repozytorium git (git add .
, git commit -a
). Aby dodać lokalny moduł do Julii:
(@v1.8)] add "/home/user/workspace/MassExcess"
Lub wysłać repozytorium na server (GitHub, GitLab, ...) i dodać je do Julii
(@v1.8)] add https://github.com/kmiernik/MassExcess
Lub zarejestrować moduł w Julia General Registry (tu jest szczegółowa instrukcja) i po zaakceptowaniu dodać go jak każdy inny
(@v1.8)] add MassExcess
Zależności
Jeżeli nasz moduł zależy od innego (powiedzmy DataFrames) to musimy normalnie powiedzieć, że chcemy zaimportować dany moduł
module MassExcess
using DataFrames
(...)
Ale także dodać DataFrames będąc w środowisku naszego pakietu
pkg> activate .
(MassExcess) pkg> add DataFrames
Lub
julia> Pkg.activate(".")
julia> Pkg.add("DataFrames")
Do pliku Manifest.toml
dodane zostanie DataFrames i wszystkie potrzebne zależności w aktualnej wersji.