să scriem împreună un simulator de automate celulare (iii)
duminică, 18 nov. 2012, 14:56
Am pus până acum bazele unui simulator de automate celulare și apoi am ajuns la o implementare funcțională conform unor specificații. Pe scurt, am obținut o mașinărie capabilă de a primi la intrare un șir de celule „goale” sau „umplute” (fiecare), prelucrându-l inductiv și obținând la fiecare pas de inducție un alt șir de celule cu aceleași proprietăți. Zisa mașinărie nu prelucrează celulele arbitrar, ci pe baza unui set de reguli care iau în calcul valoarea celulei și a vecinilor direcți, stabiliți după ordinea din șir. Putem privi șirul de șiruri rezultat fie ca pe o secvență de evenimente, fie ca pe un „covor” care are pe prima linie primul șir de celule, pe a doua șirul următor și așa mai departe.
Uneltele folosite până acum ne-au permis să vizualizăm datele doar în cel de-al doilea mod. Exemplele relevă astfel șabloane fractale precum cel al triunghiului Sierpinski, imagini regulate sau care se autoreglează „în jos”, sau dimpotrivă, reprezentări profund neregulate [i] care aduc a „randomness”. Acest al doilea mod de vizualizare oferă deci o privire sintetică asupra evoluției unui automat celular elementar oarecare.
Putem însă la fel de bine să folosim Haskell și unele din bibliotecile grafice avute la dispoziție pentru a vizualiza rezultatele în primul mod. Având la dispoziție o listă de stări, deci o listă de liste de celule, putem afișa o stare sub forma unui cadru într-o animație, extinzând astfel evoluția pe axa timpului. Propun astfel ca o celulă să fie afișată sub forma unui „petic” de 20 x 20 pixeli dintr-o imagine, după cum urmează: o celulă având valoarea E
va fi reprezentată printr-un pătrat alb, în timp ce o celulă F
va avea asociat un pătrat umplut cu negru. „Peticele” care formează o stare vor fi concatenate de la stânga la dreapta într-o imagine/cadru cu înălțimea 20 și lățimea 20 x lungimea șirului
.
Aceste considerente fiind stabilite, să definim latura pătratului ca fiind variabila rs
și să importăm câteva module Haskell ce se vor dovedi a fi utile:
import Text.Printf import Graphics.Rendering.Cairo import Cell1D rs :: Double rs = 20 |
Modulul Cell1D
cuprinde implementarea simulatorului din articolele anterioare. Text.Printf
face parte din biblioteca standard Haskell și ne va ajuta un pic mai încolo. În fine, modulul Graphics.Rendering.Cairo
, peste al cărui API vă recomand să vă uitați înainte de a continua, se va ocupa cu desenul propriu-zis și cu aruncarea acestuia într-un fișier. Imaginea noastră e o hartă bidimensională de pixeli grupați în pătrate rs
x rs
, pixelul 0 x 0 al imaginii aflându-se în colțul din stânga-sus. Primul pătrat va începe astfel la 0 x 0, al doilea la rs
x 0, al treilea la (2 * rs)
x 0 și așa mai departe. Pentru a desena o celulă sub forma unui pătrat va trebui să ținem deci cont de indexul acesteia în cadrul șirului și de starea ei (E
sau F
).
Fiind astfel înarmați cu toată informația necesară desenării unei celule, vom implementa funcția drawCell
, care apelează rectangle
și fill
pentru a scrie într-un buffer oarecare dat de monada Render
din bibliotecă:
drawCell :: Int -> Cell -> Render () drawCell n c = do let n' = fromIntegral n (r,g,b) = if c == F then (0,0,0) else (1,1,1) setSourceRGB r g b rectangle (n' * rs) 0 rs rs fill |
Observăm apelul funcției setSourceRGB
, care are ca efect lateral setarea culorii la (0,0,0)
sau (1,1,1)
în funcție de valoarea celulei. Putem eventual să procedăm într-un mod similar pentru a colora în alb toată suprafața inițială a imaginii:
drawBackground :: Int -> Render () drawBackground num = do setSourceRGB 1 1 1 rectangle 0 0 (cnum * rs) rs fill where cnum = fromIntegral num |
Ați observat probabil o funcție un pic dubioasă, fromIntegral
. Rolul ei e acela de a face cast de la un tip așa-zis „întreg” (în cazul acesta) către Double
, care e folosit de Cairo pentru desenat. Pentru cast-ul invers vom folosi floor
, care rotunjește în jos din tipuri reale în tipuri întregi.
Având la dispoziție o listă de celule, o putem desena astfel: generăm întâi o listă de indecși [0..length cs - 1]
din lista de celule cs
, după care „lipim” cele două liste folosind zip și dăm rezultatul ca argument către drawCell
. Problemele sunt două la număr: unu, trebuie să mapăm cumva funcția pe lista nou-rezultată de perechi index-celulă și doi, trebuie să aducem drawCell
într-o formă în care să poată primi ca unic argument o pereche index-celulă în loc de două argumente.
Ambele probleme pot fi rezolvate în Haskell în mod idiomatic. Cea dintâi prin aplicarea unui „map”, însă nu orice fel de map: dat fiind că suntem în cadrul monadei Render
și vrem să întoarcem un rezultat de tip ()
(adică un fel de void
al Haskell-ului), vom folosi mapM_
, deci un apel posibil ar putea fi:
mapM_ drawCell cns |
Apelul nu va trece însă de sinteza de tip, din cauza celei de-a doua probleme. Funcțiile Haskell sunt implicit în formă Curry, ceea ce înseamnă că aplicarea unui argument unei funcții cu n
argumente duce la generarea unei alte funcții care primește n - 1
argumente și așa mai departe. drawCell
primește două argumente, unul de tip Int
și încă unul de tip Cell
, ori noi vrem să putem primi un singur argument de tipul (Int, Cell)
. Din fericire funcția uncurry
știe deja să facă asta, deci codul va arăta astfel:
drawLat :: [Cell] -> Render () drawLat cs = do drawBackground $ length cs let cns = zip [0..length cs - 1] cs mapM_ (uncurry drawCell) cns |
Felul în care sunt trimise argumentele poate părea o idee greu de digerat. Nu-i bai, reveniți la tutorial, luați o foaie și un pix și studiați exemplul mai pe îndelete. Eventual puteți implementa o variantă proprie de uncurry
pentru a vă lămuri că în spate nu se petrec chestii voodoo [ii].
Generarea propriu-zisă a unei imagini e un pic mai dubioasă. Biblioteca Cairo ne impune în primul rând sa desenăm pe un obiect de tip Surface
, care-i o abstractizare a imaginii propriu-zisă. Vom face asta cu ajutorul funcției withImageSurface
. Vom folosi renderWith
pentru a desena pe „suprafață” și apoi o vom transforma într-un format de fișier convenabil, png să zicem.
cellsToFile :: FilePath -> [Cell] -> IO () cellsToFile path cells = withImageSurface FormatRGB24 pnw pnh df where pnw = length cells * floor rs pnh = floor rs df srf = do renderWith srf $ drawLat cells surfaceWriteToPNG srf path |
Observăm că withImageSurface
are nevoie de argumente întregi, deci am folosit floor
pentru a face cast la rs
în Int
.
Momentan să presupunem că nu dorim să generăm animația finală, ci doar o secvență de fișiere png de forma „prefix000.png”, „prefix001.png” și așa mai departe. Vom formata numerele sub forma „prefixabc.png” folosind printf
, care e foarte similar cu printf-ul din C. Funcția printf
va fi mapată pe un șir de numere [0..n - 1]
generat în mod similar cu cel de mai sus.
genPaths :: String -> Int -> [FilePath] genPaths prefix n = map (printf "%s%.03d.png" prefix) [0..n-1] |
În fine, acum putem abstractiza la nivelul șirurilor de stări. Folosim același idiom ca mai devreme pentru a apela cellsToFile
, în cadrul funcției latsToFiles
:
latsToFiles :: String -> [[Cell]] -> IO () latsToFiles prefix lats = mapM_ (uncurry cellsToFile) $ zip paths lats where paths = genPaths prefix $ length lats |
Să definim și o funcție main
, care ia exampleLat3
din articolul anterior și întoarce evoluția după cinci sute de iterații a regulii 184:
main = do lat0 <- exampleLat3 let result = take 500 $ runCell1D rule184 lat0 latsToFiles "myDraw" result |
Compilând și rulând exemplul, vom obține cinci sute de fișiere: „myDraw000.png„, „myDraw001.png” și așa mai departe până la „myDraw499.png”. Putem transforma în linie de comandă fișierele într-un singur gif animat folosind utilitarul convert
din cadrul bibliotecii ImageMagick:
$ convert myDraw*.png "myDraw.gif" |
„myDraw.gif” ar trebui deci să arate cam în felul următor:
În concluzie, cam așa ar arăta o implementare minimală a unui simulator de automate celulare elementare, extins cu posibilitatea de a scoate la ieșire un fișier animat. Am atașat și de această dată fișierul sursă asociat articolului, cu aceleași recomandări ca data trecută. În plus, vă propun o serie de exerciții care să vă ajute să vă faceți simulatorul mai „fancy”:
- Reimplementați animația în întregime în Haskell folosind binding-uri Haskell pentru imagemagick, trecând astfel peste necesitatea de a folosi
convert
. Rulați din nou toate exemplele făcute până acum și trageți niște concluzii pe baza lor. - Extindeți funcția
main
astfel încât să poată parsa argumente primite în linia de comandă. Argumentele trimise programului pot fi luate cu ajutorulgetArgs
. Adăugați opțiuni referitoare la lungimea șirului de celule, starea inițială, numărul de pași de execuție, „numărul” automatului și așa mai departe. Eventual starea inițială ar putea fi citită dintr-un fișier text cu „x”-uri și „.”-uri, prin implementarea unei instanțeRead
etc. - Faceți posibilă afișarea în format imagine a evoluției automatului la fel ca în articolul anterior („covor” de celule). Un exercițiu interesant l-ar putea constitui renunțarea la diverse biblioteci grafice și înlocuirea cu o bibliotecă proprie care scoate fișiere de tip SVG sau alt format vectorial ușor de parsat.
- Adăugați posibilitatea generării de automate bidimensionale, în genul Game of Life. O alternativă ar fi automatele cu latici hexagonale sau automate celulare 3D. Oricare din acestea poate fi un proiect în sine; deja aici se deschid drumuri către simulări ceva mai sofisticate ale traficului în diverse domenii sau către construcții în genul elementelor din circuitele logice. Dacă vă pasionează de exemplu fizica sau chimia, sunt sigur că există studii referitoare la dinamica unor soluții sau a unor sisteme fizice (discrete sau continue) simulate folosind automate celulare. Eu n-am dubii că puteți simula cam orice cu ajutorul lor.
- Studiul regularității automatelor celulare elementare plecând de la stări inițiale aleatoare a dus la împărțirea acestora în patru clase distincte. E demn de menționat și faptul că regularitatea de un anumit fel și interacțiunea între celule fac ca anumite automate celulare să fie echivalente computațional cu o mașină Turing. [↩]
- Problema se poate complica și mai tare. Idiomurile de tip curry-uncurry sunt doar cazuri particulare de Arrows, care iau intrările și ieșirile funcțiilor și le compun în moduri care se repetă de la un tip de calcul la altul. În caz că vă întrebați, denumirea de „Arrow” se trage de la cea de săgeată din teoria categoriilor. [↩]
Comentariile sunt dezactivate.