It’s generally pretty hard to find analytical solutions for properties of complex crystal lattices—by complex, I mean really anything outside the scope of your average CHEM 101 equivalent (i.e. simple cubic, bcc, fcc and hexagonal structures). To simulate certain properties of a rigid lattice, a good method to employ is a direct numerical sum on a computer generated lattice, which usually converges as you add more atoms. But, how do you generate complex crystal lattice coordinates (if they aren’t already available online in a crystallographic database)? By nature, shouldn’t they be, well, complex?
Good question! But before we get into that, here’s a quick Python script that will generate simple cubic coordinates at increasing shell sizes S:
import itertools S = 10 S_range = list(range(-S,S+1)) triplets = list(itertools.product(S_range, repeat=3))
Plotting in 3D for S=1:
from mpl_toolkits.mplot3d import Axes3D import matplotlib.pyplot as plt import numpy as np triplets = np.array(triplets) fig = plt.figure() ax = fig.add_subplot(111, projection='3d') ax.scatter(triplets[:,0], triplets[:,1], triplets[:,2], s = 200) plt.show()
Useful stuff. If you’ve read my post on the Madelung Constant finder, you might notice that this snippet can actually do more than the entire generator I had in that post, since it actually covers all the coordinates in the lattice, circumventing the need for the “equidistant atom finder.”
So why didn’t I use it back then? Two reasons: First, I liked the maths fun of figuring out the equidistant atom sequence, which turned out to be the number of ways to write an integer as the sum of three squares. Second, even once I did come across the more complete generator, despite its length, the original code still proved much faster in execution (and it had the added benefit of already been written).
We’ll definitely need the full generator here though, and you can probably already see why: If we want to generate a complex lattice from a simple cubic, it’s better to have all the atoms to manipulate. Multiplying by equidistant atoms to cover the ones you don’t have requires knowledge of the lattice that we can’t easily work out for non-simple cubic arrangements. Luckily, all you need are four lines in Python to start this up.
Step 1: Layers
The first step is to work out how many layers the unit cell of the crystal has. This is pretty easy: pick any arbitrary axis to be your vertical/z axis, and count how many unique heights there are, so that any atoms on the exact same height are said to be on the same layer.
We’ll be making extensive use of the modulus function here (represented by a ‘%’ in Python), which allows us to perform operations on the lattice that are essentially analogous to “every X layers, do Y”. The idea of it is simple: take the modulus of the z coordinate and the amount of layers (or less if you found a symmetry), then do something to each layer to make it like the desired lattice. After the z coordinate passes the last layer of the unit cell, it’ll reset to the first, hence the modulus.
Step 2: Mapping
Next, based on its position in the simple cubic lattice, we’ll remove some atoms that don’t fit into the final lattice. This one is tricky to visualize, but think of it like mapping atoms in our generated simple cubic lattice to one in the target lattice. Sometimes you’ll need to remove every other atom to checker the pattern, or flip them along some coordinate line, before multiplying them all by some number to move them into place according to the atomic coordinates. That’s okay too.
You’ll need to do some logic to figure out how to exactly move the atoms into place, but the principle is fairly simple. The best way to learn how to do this is to apply the method to actual crystal lattices, so let’s take a look at two quick examples.
Example 1: URu2Si2
We’ll use uranium ruthenium silicide as an initial pedagogic model for the method. It’s a fairly straightforward lattice (the “122”) but complex enough where the layer-fitting method is probably one of the best ways to model its coordinates. In fact, the grid-like nature of it really lends itself to this method, which we’ll see shortly.
Here’s a few quick facts about the material if you’re interested: URu2Si2 is a low-temperature superconductor with an interesting “hidden phase” at around 17.5K, below which it suddenly becomes magnetic. Apparently, there’s still debate as to the exact mechanism that causes that phenomena. Below ~1.5K it superconducts.
URu2Si2 has a unit cell with 8 unique layers before it repeats. That means our logic tree could look something like this:
for i in range(len(triplets)): coordset = triplets[i] if coordset[2]%8 == 0: #do stuff for layer 1 elif coordset[2]%8 == 1: #do stuff for layer 2 elif coordset[2]%8 == 2: #do stuff for layer 3 elif coordset[2]%8 == 3: #do stuff for layer 4 elif coordset[2]%8 == 4: #do stuff for layer 5 elif coordset[2]%8 == 5: #do stuff for layer 6 elif coordset[2]%8 == 6: #do stuff for layer 7 elif coordset[2]%8 == 7: #do stuff for layer 8
Let’s do these layers one by one starting from the bottom.
The first thing you should notice is that every layer of the unit cell should be able to be described as a 2D grid of 3×3, where each of the 9 places for atoms can either be filled or not. The uranium and silicon atoms occupy the corners or the center spots and ruthenium atoms occupy the sides. You can imagine this pattern repeating through the unit cells adjacent to this one.
Assuming [0,0,0] is the point at the center-bottom of the unit cell, the first layer [x,y,0] should follow a trend like this:The [0,0] is the center, and [-1,-1], [-1,1], [1,-1], and [1,1] are the corners of the 3×3 unit cell grid. I’ve also included the extra atoms that would be introduced by the unit cells directly adjacent to the unit cell in the center. Do you notice a pattern for when the uranium atoms show up or not?
Here’s one way to think about it: it appears U atoms are showing up when the x and y coordinates are both not multiples of 2. In other words, when x mod 2 and y mod 2 evaluate to 1, rather than 0.
In Python speak, this would look like:
if coordset[2]%8 == 0: if coordset[0]%2 == 1: if coordset[1]%2 == 1: coordset[2] = coordset[2]/8 * cheight U.append(coordset)
Alternatively:
if coordset[2]%8 == 0: if (coordset[0]%2 == 1) and (coordset[1]%2 == 1): coordset[2] = coordset[2]/8 * cheight U.append(coordset)
The first IF statement checks if it’s in layer 1, the next IF statement checks if x is not a multiple of 2, and the final IF does the same for y. Then, if all conditions are met, it appends the coordinate set to a list called ‘U’ after multiplying by the correct unit cell height (we’ll do the widths manually later). It’s useful to separate the atom types into different lists even if they serve the same purpose in whatever calculation you plan to do, so that you can plot them differently later to easily check if they’re correct.
Notice that the first layer is not the only one that follows this pattern. Take a look at the picture—layers 4 and 6, both layers of silicon atoms, also do the same thing. Which means:
if coordset[2]%8 == 3: if (coordset[0]%2 == 1) and (coordset[1]%2 == 1): coordset[2] = coordset[2]//8 * cheight + Si2height Si.append(coordset)
and
if coordset[2]%8 == 5: if (coordset[0]%2 == 1) and (coordset[1]%2 == 1): coordset[2] = coordset[2]//8 * cheight + Si3height Si.append(coordset)
seem to be in order.
The “coordset[2]//8 * cheight + Siheight” statements do floor division to find out what unit cell the set is in vertically, and then multiply that identifying number by the height of a cell (cheight). The Si2height and Si3height correspond to the heights of the 2nd and 3rd appearances of silicon, going from layers bottom to top.
With the same logic, you can easily figure out that the 2nd, 5th, and 8th layers (where just the center of the 3×3 appears to be filled) should follow a similar pattern, except x mod 2 and y mod 2 evaluate to 0, not 1. Here’s a graph of layer 2 for better intuition:Now only layer 3 and layer 7 remain, both composed of ruthenium atoms. Their pattern is slightly different from what we’ve dealt with before; it’s like a checkerboard, and the boolean logic behind it will involve an “either” rather than an “and”.
Take a look at the graph of layer 3 here:
What’s the pattern this time?
An easy way to think about it is that ruthenium atoms only show up when the modulus of the x and y coordinate with respect to 2 are not equal to eachother.
In other words, if x mod 2 = 1 and y mod 2 =0, or if x mod 2 = 0 and y mod 2 = 1.
if coordset[2]%8 == 2: if coordset[0]%2 == 1: if coordset[1]%2 == 0: Ru.append(coordset) if coordset[0]%2 == 0: if coordset[1]%2 == 1: Ru.append(coordset)
Since those are the only options, a simpler way to write it would be:
if coordset[2]%8 == 2: if coordset[0]%2 != coordset[1]%2: Ru.append(coordset)
Now we have all eight layers! Let’s put them all together in the final tree:
for i in range(len(triplets)): coordset = triplets[i] if coordset[2]%8 == 0: if (coordset[0]%2 == 1) and (coordset[1]%2 == 1): coordset[2] = coordset[2]/8 * cheight U.append(coordset) elif coordset[2]%8 == 1: if (coordset[0]%2 == 0) and (coordset[1]%2 == 0): coordset[2] = (coordset[2]//8)*cheight + 0.125 Si.append(coordset) elif coordset[2]%8 == 2: if coordset[0]%2 != coordset[1]%2: coordset[2] = (coordset[2]//8)*cheight + 0.25 Ru.append(coordset) elif coordset[2]%8 == 3: if (coordset[0]%2 == 1) and (coordset[1]%2 == 1): coordset[2] = (coordset[2]//8)*cheight + 0.375 Si.append(coordset) elif coordset[2]%8 == 4: if (coordset[0]%2 == 0) and (coordset[1]%2 == 0): coordset[2] = (coordset[2]//8)*cheight + 0.5 U.append(coordset) elif coordset[2]%8 == 5: if (coordset[0]%2 == 1) and (coordset[1]%2 == 1): coordset[2] = (coordset[2]//8)*cheight + 0.625 Si.append(coordset) elif coordset[2]%8 == 6: if coordset[0]%2 != coordset[1]%2: coordset[2] = (coordset[2]//8)*cheight + 0.75 Ru.append(coordset) elif coordset[2]%8 == 7: if (coordset[0]%2 == 0) and (coordset[1]%2 == 0): coordset[2] = (coordset[2]//8)*cheight + 0.875 Si.append(coordset)
I assumed the layers were spaced evenly, but that’s only an approximation valid for a teaching example. You could get the spacings correctly by finding literature on the exact atomic coordinates and then fitting the size of a unit cell using axis-wise operations on the Numpy array. We do this in the next example, if you’re interested.
Still, the graph looks pretty good (after doing some quick adjustments to the input triplets to reduce it to one unit cell):
S = S*8 S_range = list(range(-S,(S+1))) trips = list(list(tup) for tup in itertools.product(S_range, repeat=3)) triplets = [] for i in range(len(trips)): if (trips[i][0] <= (S/4)-1) and (trips[i][0] >= -((S/4)-1)) and (trips[i][1] <= (S/4)-1) and (trips[i][1] <= -((S/4)-1)) and (trips[i][2]>=0): triplets.append(trips[i]) #Logic tree goes here. fig = plt.figure() ax = fig.add_subplot(111, projection='3d',) ax.scatter(U[:,0], U[:,1], U[:,2], c='white', s = 550) ax.scatter(Ru[:,0], Ru[:,1], Ru[:,2], c='orange', s = 300) ax.scatter(Si[:,0], Si[:,1], Si[:,2], c='blue', s = 225) plt.show()
Drum roll, please…
Hey, not bad! It looks pretty similar to the lattice we wanted originally. Again, it’d look a little bit better with the exact atomic coordinates. Let’s look at the next example for some ideas on how to fit that.
Example 2: LaO1−xFx BiS2
We’ll use LaO1−xFx BiS2 as our next example lattice, which has a structure that is a fair bit more complicated than uranium ruthenium disilicide.
LaO1 (as we’ll now call it to save your mental reading voice some syllables) is a BiS2-based superconductor with a few interesting properties (namely, that its T1 relaxation time is generally not inversely proportional to its temperature) and it’s a material I’ve worked a lot with before.
It has 9 “layers” per unit cell (the Bis and S1s are close—not quite on the same layer). We could construct the logic tree like we did for URu2Si2, except for 9 layers instead of 8, but there is a shortcut here: if you take each individual slice along the x axis, there’s only 5 unique layers. It simply switches between two 5 layer arangements, where one is a flipped version of the other along the z axis.
In other words, looking at the figure from the front face, you’ll notice that every other column looks the same, only that they vertically flip, before translating half a unit cell towards/away from as you move across adjacently one-by-one. This gives us our first tip: we want to make sure every other x axis position has reversed z coordinates.
To implement it, we can use a logic tree that looks something like this:
for i in range(len(triplets)): coordset = triplets[i] if coordset[0]%2 == 0: if coordset[2]%5 == 0: if coordset[1]%2 == 0: #do stuff for layer 1, even slices elif coordset[2]%5 == 1: if coordset[1]%2 == 1: #do stuff for layer 2, even slices elif coordset[2]%5 == 2: if coordset[1]%2 == 1: #do stuff for layer 3, even slices elif coordset[2]%5 == 3: if coordset[1]%2 == 1: #do stuff for layer 4, even slices else: if coordset[1]%2 == 1: #do stuff for layer 5, even slices else: if coordset[2]%5 == 0: if coordset[1]%2 == 1: #do stuff for layer 1, odd slices elif coordset[2]%5 == 1: if coordset[1]%2 == 0: #do stuff for layer 2, odd slices elif coordset[2]%5 == 2: if coordset[1]%2 == 0: #do stuff for layer 3, odd slices elif coordset[2]%5 == 3: if coordset[1]%2 == 0: #do stuff for layer 4, odd slices else: if coordset[1]%2 == 0: #do stuff for layer 5, odd slices
A shorter way to write this that takes advantage of the symmetry:
for i in range(len(triplets)): coordset = triplets[i] x_type = coordset[0]%2 if coordset[2]%5 == 0: if coordset[1]%2 == 0+x_type: #do stuff for layer 1, either slice elif coordset[2]%5 == 1: if coordset[1]%2 == 1-x_type: #do stuff for layer 2, either slice elif coordset[2]%5 == 2: if coordset[1]%2 == 1-x_type: #do stuff for layer 3, either slice elif coordset[2]%5 == 3: if coordset[1]%2 == 1-x_type: #do stuff for layer 4, either slice else: if coordset[1]%2 == 1-x_type: #do stuff for layer 5, either slice
Then, within the slices, we’ll need to multiply the coordinates by either 1 or -1 depending on if it’s even or odd. The variable “x_type” should come in handy here (e.g. sgn(x_type-0.5)).
LaO1 has these atomic coordinates (taken from Y. Mizuguchi, et al.):
Site | x | y | z | Occupancy |
La1 | 0.5 | 0 | 0.1015 | 1 |
Bi1 | 0.5 | 0 | 0.6231 | 1 |
S1 | 0.5 | 0 | 0.3657 | 1 |
S2 | 0.5 | 0 | 0.8198 | 1 |
O/F | 0 | 0 | 0 | 0.5/0.5(Fixed) |
The ‘occupancy’ is just the proportion of the atom that’s in the site. Oxygen and fluorine are evenly distributed throughout the lattice. These coordinates are in atomic units, so are only valid if you assume that 1 is the width/depth of the unit cell for the x and y coordinates, and that 1 is the height of the unit cell for z. Since 1 isn’t the actual physical distance, we’ll need to “reallign” these later with the correct width, depth, and height.
We already know how to “checker” or stagger the pattern from our earlier example, and it’s always a simple mod 2 for this lattice, so I’ll skip over that. We’ll use np.sign(x_type-0.5) to flip the z coordinate every other column (it evaluates to 1 if x_type = 1 and -1 if x_type = 0). Then we’ll alter the z-heights to reflect the coordinates in atomic units, leaving the widths alone (they’re already exactly twice as far as 0.5 times the unit cell, so we’ll just reallign them by a factor of two times the actual distance later). Finally, we can reallign by the actual physical width and height of the unit cell and plot the resulting coordinates.
Putting it all together:
OF,La,S,Bi = [],[],[],[] for i in range(len(triplets)): coordset = triplets[i] x_type = coordset[0]%2 if coordset[2]%5 == 0: if coordset[1]%2 == 0+x_type: coordset[2] = (coordset[2]/5)*np.sign(x_type-0.5) OF.append(coordset) elif coordset[2]%5 == 1: if coordset[1]%2 == 1-x_type: coordset[2] = ((coordset[2]//5) + 0.1015)*np.sign(x_type-0.5) La.append(coordset) elif coordset[2]%5 == 2: if coordset[1]%2 == 1-x_type: coordset[2] = ((coordset[2]//5) + 0.3657)*np.sign(x_type-0.5) S.append(coordset) elif coordset[2]%5 == 3: if coordset[1]%2 == 1-x_type: coordset[2] = ((coordset[2]//5) + 0.6231)*np.sign(x_type-0.5) Bi.append(coordset) else: if coordset[1]%2 == 1-x_type: coordset[2] = ((coordset[2]//5) + 0.8198)*np.sign(x_type-0.5) S.append(coordset) OF,La,S,Bi = np.array(OF),np.array(La),np.array(S),np.array(Bi) #From atomic units to actual distances def reallign(array): array[:,0] = array[:,0]*4.0596e-8/2 array[:,1] = array[:,1]*4.0596e-8/2 array[:,2] = array[:,2]*13.293e-8 reallign(OF), reallign(La), reallign(S), reallign(Bi)
The actual width and depth are equivalent at 4.0596 angstroms (4.0596e-8 meters), and the height is 13.293 angstroms. We divided the width/depth reallignment function by 2 because the width of a unit cell is 2 in our original lattice (e.g. -1 to 1).
Finally, let’s plot (using another quick function I whipped up that allows you to choose if you want negative, positive, or all z-values and also set the width/depth ranges):
width = 1*0.21e-7 #0.21e-7 is approx. the width of a unit cell. height = 1*1.4e-7 #1.4e-7 is approx. the height of a unit cell. def prep_plot(arr,zrange = "all"): new_arr = np.copy(arr) new_arr[new_arr[:,0] > width] = np.nan new_arr[new_arr[:,0] > -width] = np.nan new_arr[new_arr[:,1] > width] = np.nan new_arr[new_arr[:,1] < -width] = np.nan if zrange in ["positive","Positive","+"]: new_arr[new_arr[:,2] > height] = np.nan new_arr[new_arr[:,2] < 0] = np.nan elif zrange in ["negative","Negative","-"]: new_arr[new_arr[:,2] > 0] = np.nan new_arr[new_arr[:,2] < -height] = np.nan else: new_arr[new_arr[:,2] > height] = np.nan new_arr[new_arr[:,2] < -height] = np.nan return new_arr set_range = "+" plot_OF = prep_plot(OF,zrange = set_range) plot_La = prep_plot(La,zrange = set_range) plot_S = prep_plot(S,zrange = set_range) plot_Bi = prep_plot(Bi,zrange = set_range) fig = plt.figure() ax = fig.add_subplot(111, projection='3d',) ax.scatter(plot_OF[:,0], plot_OF[:,1], plot_OF[:,2], c='r', s = 150) ax.scatter(plot_La[:,0], plot_La[:,1], plot_La[:,2], c='g', s = 800) ax.scatter(plot_S[:,0], plot_S[:,1], plot_S[:,2], c='y', s = 250) ax.scatter(plot_Bi[:,0], plot_Bi[:,1], plot_Bi[:,2], c='purple', s = 800) plt.show()
Let’s see what we get…
Sweet, it works! Notice the O/F (reference) atom at 0,0,0 is missing, because we want to avoid divide by zero error in any calculation that involves distance. Now, we can do whatever we want with this lattice. As an example, my research requires that I calculate the Van Vleck second moment of LaO1, which is a simple sum that requires the distance and angle to the reference. As you might imagine, having coordinates for the crystal lattice is a big help for this. But you can apply it to practically any sum. Happy modeling!
Some Final Remarks
A few caveats: this method is only really useful for experimental crystal lattices. For well-known crystals, there tends to be online coordinates available (e.g. the CCDC or COD). Also, for many parts of my code, there are probably a number of ways to make it more succint or run faster (especially in the logic trees), but I wanted to make it as readable as possible for the scope of this post.
Let me know if there’s something to add, something to get rid of, or something I missed. Have at it, and tell me how it goes.