In this assignment we will look at cluster growth in a pool of initially single-atom clusters, which will grow through mutual attachment. The template code is in Clusters.py and Read_Type.py.
The code is organized into four different classes:
The following very detailed steps are ordered such that you should have a working system at any point. Make sure to run your code often, it is much easier to spot problems this way. You can add additional helper functions and variables anywhere in the code, but do not change the predefined function signatures and member variable names!
Begin by running the template code as it is. The pale pink circles are the sites that the GridEnvironment reports as occupied. The blue squares are the reported cluster atom's positions. In a working code, these two should always agree.
In __init__, define a variable that keeps track of all the occupied cells on a square grid. Each cell position is given as an (x,y) tuple where x and y are integers.
Fix fill to add positions to your newly defined grid. If a requested position is full already, return False, otherwise, mark the position as full and return True.
Fix randpos to return a random empty cell from your grid.
Fix filled to return two lists, of the x- and y-positions of all occupied grid cells. (You shouldn't include the template return values anymore, of course)
The plot should now show the right number of pink circles randomly scattered over the grid. We haven't done anything yet about the blue cluster points.
The __init__ function of Cluster asks the environment for a random position, and creates a first atom to make up the cluster. It also tells the environment that the position is now occupied. What we saw at the end of part A were the positions of these initial atoms as reported by the GridEnvironment. The Clusters themselves do not report their positions correctly yet.
Implement a storage for Atom objects and their positions in Cluster's __init__. In the init function, this storage will be filled with only a single atom, as every cluster should start out this way. The number of stored atoms will grow through subsequent calls to 'merge' which we'll get to later on. As above, the position is an integer 2-tuple (x,y).
Implement Cluster's pos function to return a numpy array of the list of atom positions that belong to this cluster. (Again, remove the template values first!)
If the clusters' idea of their own positions and the occupied grid cells agree, every pink circle should now be filled with a blue square. Let's make them move next!
Implement Cluster's randStep to return a single step along one of the four axis directions, formatted as a (dx,dy) tuple.
In Cluster step, move all member atoms one step in a direction chosen by self.randStep(). After the step, the Cluster should have moved by one cell as a rigid body. Remember to tell the GridEnvironment which cells are now full and which are now free. To do this, you'll also need...
GridEnvironment erase, which should try to clear a grid position. If it is already empty, raise a GridException.
Something you can leave until after section E or try now: If any member atom encounters a full grid cell — other members of its own cluster don't count — leave the whole cluster as it was before. Do _not_ move parts of the cluster and leave parts behind.
This is easy to do when each cluster contains only one atom, but you should test that this also works when Cluster __init__ is started off with e.g. two Atoms at two different randpos() positions. Check this by running only two or three clusters instead of the default 100. Each one should move as a coordinated group. Once you're happy with the outcome, change __init__ back to only start with one Atom.
It's time to watch 100 random walkers slowly leave the arena. Make sure that there always is agreement between the pink circles and blue squares!
Revisit the GridEnvironment class to implement boundary conditions. The grid should range from 0 to self.size in both directions. If self.cyclic is True, the edges should be periodic, i.e. a walker leaving the top of the arena should reappear at the bottom (the same thing for the left and right edges). Otherwise, if self.cyclic is False, the arena should be bounded at the edges. One way of doing this is to permanently mark those edge points as filled.
To actually constrain the walkers (especially in the cyclical case), one new function needs to be filled: impose_limit. It should accept any (x,y) position, and return the equivalent position within the main grid square.
Use impose_limit for the Cluster's step function.
To check cyclic=False, you'll need to edit the first line in the main testing code, where the GridEnvironment is set up.
If everything works up until this point, you'll have around 50–60% of the marks (depending on the answer for C4).
To get clusters to stick together, we'll first need to establish if they touch each other. Implement Cluster's touches function such that it returns a list of (position,atom) tuples of all atoms that are next to the given position (diagonals do not count).
Additionally, use the self.affinity parameter to control inclusion in the 'touches' return list. For each possible candidate neighbour, only include it with a chance of self.affinity: for 1.0, it is always included, for 0.0 never, and for e.g. 0.3 any given neighbour should be included in the return list with a 30% chance.
Implement Cluster's merge: Given a second cluster 'c2', 'self' should take over all of c2's member atoms, and leave c2 empty.
Now for the main work: the handling of cluster merges needs to be done at the level of the ClusterGroup class. The version of ClusterGroup's step in the template code simply loops over all Clusters in the group and tells them to do one step each. This needs to be extended:
After all clusters have stepped, check for pairs of clusters that now lie next to each other (use the 'touches' function).
If you have found an adjacent pair, merge them using the 'merge' function from above. One attachment point per pair is sufficient, there's no need to multiply attach clusters that share a whole boundary. Then continue looking for all other possible merges.
In the animation, the 100 atoms should now slowly condense into larger clusters, and eventually, after a few hundred steps, just one single cluster remains.
Parts F and G can be done in any order.
Up to now, any two touching Atoms will connect up along the four directions. Let us introduce atomic species with fewer than four connection points (from now on called 'arms').
The directions in which the arms can point are given as numbers from 0 to 3, where 0=up, 1=left, 2=down, 3=right.
In the Atom class, add member variable(s) to __init__ which keep track of the direction and status of the N arms.
Implement canAttach to return True if an arm pointing in the requested direction is free, and False otherwise.
We need to implement canAttach and doAttach separately, since the actual attachment will only happen when both participating atoms have an arm free. More on that below in point 4.
Implement doAttach to mark one arm as attached along the given direction. You can assume that 'canAttach' has already been checked, so that the arm must have been free.
Use these two attachment functions in your work from part E to only allow attachment if
Remember to mark the arms as busy after a successful merge.
Modify canAttach in such a way that 2-armed atoms can be rigid or flexible. If self.flexible is False, the first arm can attach in any direction, but the second arm must point opposite to the first one.
To see these options in the animation, you'll need to modify the top of the main program slightly. Comment out the current 'groups' lines and use one of the provided alternatives which set the 'Narms' list.
At the end of a run on a non-cyclic grid, every cluster group prints its fractal dimension.
Modify the global function fractaldim. It takes in an arrangement of 2D points given by a list of xs and ys, and should return an approximation of the fractal dimension of that arrangement. Calculate that fractal dimension as shown in the lecture:
Since the clusters in our runs are typically not very large, you'll only be able to approximate the slope very roughly. Try to make the result as good as possible in the limit of large clusters. You could test your function with several pre-prepared lists of xs and ys to test the 2D and 1D limits of the calculation.
There are no marks to be gained down here!
Just a list of fun things we found when we played with the code...
Remember the Planets code? Try these initial parameters:
G = 1.0 M = mE = mJ = 1.0 x0E = 0.97000436 y0E = -0.24308753 x0J = -x0E y0J = -y0E vxS = -0.93240737 vyS = -0.86473146 vxE = -0.5*vxS vyE = -0.5*vyS vxJ = -0.5*vxS vyJ = -0.5*vyS x = np.array([0.,0.,vxS,vyS,x0E,y0E,vxE,vyE,x0J,y0J,vxJ,vyJ]) t0 = 0 t1 = 12. deltat = 0.001