bsym
– a basic symmetry modulebsym
is a basic Python symmetry module. It consists of some core classes that describe configuration vector spaces, their symmetry operations, and specific configurations of objects withing these spaces. The module also contains an interface for working with pymatgen
Structure
objects, to allow simple generation of disordered symmetry-inequivalent structures from a symmetric parent crystal structure.
API documentation is here.
The central object described by bsym
is the configuration space. This defines a vector space that can be occupied by other objects. For example; the three points $a, b, c$ defined by an equilateral triangle,
which can be described by a length 3 vector:
\begin{pmatrix}a\\b\\c\end{pmatrix}If these points can be coloured black or white, then we can define a configuration for each different colouring (0 for white, 1 for black), e.g.
with the corresponding vector
\begin{pmatrix}1\\1\\0\end{pmatrix}A specific configuration therefore defines how objects are distributed within a particular configuration space.
The symmetry relationships between the different vectors in a configuration space are described by symmetry operations. A symmetry operation describes a transformation of a configuration space that leaves it indistinguishable. Each symmetry operation can be describes as a matrix that maps the vectors in a configuration space onto each other, e.g. in the case of the equiateral triangle the simplest symmetry operation is the identity, $E$, which leaves every corner unchanged, and can be represented by the matrix
\begin{equation} E=\begin{pmatrix}1 & 0 & 0\\0 & 1 & 0 \\ 0 & 0 & 1\end{pmatrix} \end{equation}For this triangular example, there are other symmetry operations, including reflections, $\sigma$ and rotations, $C_n$:
In this example reflection operation, $b$ is mapped to $c$; $b\to c$, and $c$ is mapped to $b$; $b\to c$.
The matrix representation of this symmetry operation is
\begin{equation} \sigma_\mathrm{a}=\begin{pmatrix}1 & 0 & 0\\0 & 0 & 1 \\ 0 & 1 & 0\end{pmatrix} \end{equation}For the example rotation operation, $a\to b$, $b\to c$, and $c\to a$, with matrix representation
\begin{equation} C_3=\begin{pmatrix}0 & 0 & 1\\ 1 & 0 & 0 \\ 0 & 1 & 0\end{pmatrix} \end{equation}Using this matrix and vector notation, the effect of a symmetry operation on a specific configuration can be calculated as the matrix product of the symmetry operation matrix and the configuration vector:
In matrix notation this is represented as
\begin{equation} \begin{pmatrix}0\\1\\1\end{pmatrix} = \begin{pmatrix}0 & 0 & 1\\ 1 & 0 & 0 \\ 0 & 1 & 0\end{pmatrix}\begin{pmatrix}1\\1\\0\end{pmatrix} \end{equation}or more compactly
\begin{equation} c_\mathrm{f} = C_3 c_\mathrm{i}. \end{equation}The set of all symmetry operations for a particular configuration space is a group.
For an equilateral triangle this group is the $C_{3v}$ point group, which contains six symmetry operations: the identity, three reflections (each with a mirror plane bisecting the triangle and passing through $a$, $b$, or $c$ respectively) and two rotations (120° clockwise and counterclockwise).
\begin{equation} C_{3v} = \left\{ E, \sigma_\mathrm{a}, \sigma_\mathrm{b}, \sigma_\mathrm{c}, C_3, C_3^\prime \right\} \end{equation}bsym
SymmetryOperation
classIn bsym
, a symmetry operation is represented by an instance of the SymmetryOperation
class. A SymmetryOperation
instance can be initialised from the matrix representation of the corresponding symmetry operation.
For example, in the trigonal configuration space above, a SymmetryOperation
describing the identify, $E$, can be created with
In [1]:
from bsym import SymmetryOperation
In [2]:
SymmetryOperation([[ 1, 0, 0 ],
[ 0, 1, 0 ],
[ 0, 0, 1 ]])
Out[2]:
Each SymmetryOperation
has an optional label
attribute. This can be set at records the matrix representation of the symmetry operation and an optional label. We can provide the label when creating a SymmetryOperation
:
In [3]:
SymmetryOperation([[ 1, 0, 0 ],
[ 0, 1, 0 ],
[ 0, 0, 1 ]], label='E' )
Out[3]:
or set it afterwards:
In [4]:
e = SymmetryOperation([[ 1, 0, 0 ],
[ 0, 1, 0 ],
[ 0, 0, 1 ]])
e.label = 'E'
e
Out[4]:
Or for $C_3$:
In [5]:
c_3 = SymmetryOperation( [ [ 0, 0, 1 ],
[ 1, 0, 0 ],
[ 0, 1, 0 ] ], label='C3' )
c_3
Out[5]:
The matrix representation of a symmetry operation is a permutation matrix. Each row maps one position in the corresponding configuration space to one other position. An alternative, condensed, representation for each symmetry operation matrix uses vector notation, where each element gives the row containing 1
in the equivalent matrix column. e.g. for $C_3$ the vector mapping is given by $\left[2,3,1\right]$, corresponding to the mapping $1\to2$, $2\to3$, $3\to1$.
In [6]:
c_3_from_vector = SymmetryOperation.from_vector( [ 2, 3, 1 ], label='C3' )
c_3_from_vector
Out[6]:
The vector representation of a SymmetryOperation
can be accessed using the as_vector()
method.
In [7]:
c_3.as_vector()
Out[7]:
In [8]:
c_3 = SymmetryOperation.from_vector( [ 2, 3, 1 ], label='C3' )
c_3_inv = SymmetryOperation.from_vector( [ 3, 1, 2 ], label='C3_inv' )
print( c_3, '\n' )
print( c_3_inv, '\n' )
The product of $C_3$ and $C_3^\prime$ is the identity, $E$.
In [9]:
c_3 * c_3_inv
Out[9]:
c_3_inv
can also be generated using the .invert()
method
In [10]:
c_3.invert()
Out[10]:
The resulting SymmetryOperation
does not have a label defined. This can be set directly, or by chaining the .set_label()
method, e.g.
In [11]:
c_3.invert( label= 'C3_inv')
Out[11]:
In [12]:
c_3.invert().set_label( 'C3_inv' )
Out[12]:
SymmetryGroup
classA SymmetryGroup
is a collections of SymmetryOperation
objects. A SymmetryGroup
is not required to contain all the symmetry operations of a particular configuration space, and therefore is not necessarily a complete mathematical group.
For convenience bsym
has PointGroup
and SpaceGroup
classes, that are equivalent to the SymmetryGroup
parent class.
In [13]:
from bsym import PointGroup
In [14]:
# construct SymmetryOperations for C_3v group
e = SymmetryOperation.from_vector( [ 1, 2, 3 ], label='e' )
c_3 = SymmetryOperation.from_vector( [ 2, 3, 1 ], label='C_3' )
c_3_inv = SymmetryOperation.from_vector( [ 3, 1, 2 ], label='C_3_inv' )
sigma_a = SymmetryOperation.from_vector( [ 1, 3, 2 ], label='S_a' )
sigma_b = SymmetryOperation.from_vector( [ 3, 2, 1 ], label='S_b' )
sigma_c = SymmetryOperation.from_vector( [ 2, 1, 3 ], label='S_c' )
In [15]:
c3v = PointGroup( [ e, c_3, c_3_inv, sigma_a, sigma_b, sigma_c ] )
In [16]:
c3v
Out[16]:
In [17]:
from bsym import ConfigurationSpace
In [18]:
c = ConfigurationSpace( objects=['a', 'b', 'c' ], symmetry_group=c3v )
In [19]:
c
Out[19]:
Configuration
classA Configuration
instance describes a particular configuration, i.e. how a set of objects are arranged within a configuration space. Internally, a Configuration
is represented as a vector (as a numpy
array).
Each element in a configuration is represented by a single digit non-negative integer.
In [20]:
from bsym import Configuration
conf_1 = Configuration( [ 1, 1, 0 ] )
conf_1
Out[20]:
The effect of a particular symmetry operation acting on a configuration can now be calculated using the SymmetryOperation.operate_on()
method, or by direct multiplication, e.g.
In [21]:
c1 = Configuration( [ 1, 1, 0 ] )
c_3 = SymmetryOperation.from_vector( [ 2, 3, 1 ] )
c_3.operate_on( c1 )
Out[21]:
In [22]:
c_3 * conf_1
Out[22]:
A common question that comes up when considering the symmetry properties of arrangements of objects is: how many ways can these be arranged that are not equivalent by symmetry?
As a simple example of solving this problem using bsym
consider four equivalent sites arranged in a square.
In [23]:
c = ConfigurationSpace( [ 'a', 'b', 'c', 'd' ] ) # four vector configuration space
This ConfigurationSpace
has been created without a symmetry_group
argument. The default behaviour in this case is to create a SymmetryGroup
containing only the identity, $E$.
In [24]:
c
Out[24]:
We can now calculate all symmetry inequivalent arrangements where two sites are occupied and two are unoccupied, using the unique_configurations()
method. This takes as a argument a dict
with the numbers of labels to be arranged in the configuration space. Here, we use the labels 1
and 0
to represent occupied and unoccupied sites, respectively, and the distribution of sites is given by { 1:2, 0:2 }
.
In [25]:
c.unique_configurations( {1:2, 0:2} )
Out[25]:
Because we have not yet taken into account the symmetry of the configuration space, we get
\begin{equation} \frac{4\times3}{2} \end{equation}unique configurations (where the factor of 2 comes from the occupied sites being indistinguishable).
The configurations generated by unique_configurations
have a count
attribute that records the number of symmetry equivalent configurations of each case:
In this example, each configuration appears once:
In [26]:
[ uc.count for uc in c.unique_configurations( {1:2, 0:2} ) ]
Out[26]:
We can also calculate the result when all symmetry operations of this configuration space are included.
In [27]:
# construct point group
e = SymmetryOperation.from_vector( [ 1, 2, 3, 4 ], label='E' )
c4 = SymmetryOperation.from_vector( [ 2, 3, 4, 1 ], label='C4' )
c4_inv = SymmetryOperation.from_vector( [ 4, 1, 2, 3 ], label='C4i' )
c2 = SymmetryOperation.from_vector( [ 3, 4, 1, 2 ], label='C2' )
sigma_x = SymmetryOperation.from_vector( [ 4, 3, 2, 1 ], label='s_x' )
sigma_y = SymmetryOperation.from_vector( [ 2, 1, 4, 3 ], label='s_y' )
sigma_ac = SymmetryOperation.from_vector( [ 1, 4, 3, 2 ], label='s_ac' )
sigma_bd = SymmetryOperation.from_vector( [ 3, 2, 1, 4 ], label='s_bd' )
c4v = PointGroup( [ e, c4, c4_inv, c2, sigma_x, sigma_y, sigma_ac, sigma_bd ] )
# create ConfigurationSpace with the c4v PointGroup.
c = ConfigurationSpace( [ 'a', 'b', 'c', 'd' ], symmetry_group=c4v )
c
Out[27]:
In [28]:
c.unique_configurations( {1:2, 0:2} )
Out[28]:
In [29]:
[ uc.count for uc in c.unique_configurations( {1:2, 0:2 } ) ]
Out[29]:
Taking symmetry in to account, we now only have two unique configurations: either two adjacent site are occupied (four possible ways), or two diagonal sites are occupied (two possible ways):
The unique_configurations()
method can also handle non-binary site occupations:
In [30]:
c.unique_configurations( {2:1, 1:1, 0:2} )
Out[30]:
In [31]:
[ uc.count for uc in c.unique_configurations( {2:1, 1:1, 0:2 } ) ]
Out[31]:
pymatgen
One example where the it can be useful to identify symmetry-inequivalent arrangements of objects in a vector space, is when considering the possible arrangements of disordered atoms on a crystal lattice.
To solve this problem for an arbitrary crystal structure, bsym
contains an interface to pymatgen
that will identify symmetry-inequivalent atom substitutions in a given pymatgen
Structure
.
As an example, consider a $4\times4$ square-lattice supercell populated by lithium atoms.
In [32]:
from pymatgen import Lattice, Structure
import numpy as np
In [33]:
# construct a pymatgen Structure instance using the site fractional coordinates
coords = np.array( [ [ 0.0, 0.0, 0.0 ] ] )
atom_list = [ 'Li' ]
lattice = Lattice.from_parameters( a=1.0, b=1.0, c=1.0, alpha=90, beta=90, gamma=90 )
parent_structure = Structure( lattice, atom_list, coords ) * [ 4, 4, 1 ]
parent_structure.cart_coords.round(2)
Out[33]:
We can use the bsym.interface.pymatgen.unique_structure_substitutions()
function to identify symmetry-inequivalent structures generated by substituting at different sites.
In [34]:
from bsym.interface.pymatgen import unique_structure_substitutions
In [35]:
print( unique_structure_substitutions.__doc__ )
As a trivial example, when substituting one Li atom for Na, we get a single unique structure
In [36]:
unique_structures = unique_structure_substitutions( parent_structure, 'Li', { 'Na':1, 'Li':15 } )
len( unique_structures )
Out[36]:
In [37]:
na_substituted = unique_structures[0]
This Li$\to$Na substitution breaks the symmetry of the $4\times4$ supercell.
If we now replace a second lithium with a magnesium atom, we generate five symmetry inequivalent structures:
In [38]:
unique_structures_with_Mg = unique_structure_substitutions( na_substituted, 'Li', { 'Mg':1, 'Li':14 } )
len( unique_structures_with_Mg )
Out[38]:
In [39]:
[ s.number_of_equivalent_configurations for s in unique_structures_with_Mg ]
Out[39]:
number_of_equivalent_configurations
only lists the number of equivalent configurations found when performing the second substitution, when the list of structures unique_structures_with_Mg
was created. The full configuration degeneracy relative to the initial empty 4×4 lattice can be queried using full_configuration_degeneracy
.
In [40]:
[ s.full_configuration_degeneracy for s in unique_structures_with_Mg ]
Out[40]:
In [41]:
# Check the squared distances between the Na and Mg sites in these unique structures are [1, 2, 4, 5, 8]
np.array( sorted( [ s.get_distance( s.indices_from_symbol('Na')[0],
s.indices_from_symbol('Mg')[0] )**2 for s in unique_structures_with_Mg ] ) )
Out[41]:
This double substitution can also be done in a single step:
In [42]:
unique_structures = unique_structure_substitutions( parent_structure, 'Li', { 'Mg':1, 'Na':1, 'Li':14 } )
In [43]:
len(unique_structures)
Out[43]:
In [44]:
np.array( sorted( [ s.get_distance( s.indices_from_symbol('Na')[0],
s.indices_from_symbol('Mg')[0] ) for s in unique_structures ] ) )**2
Out[44]:
In [45]:
[ s.number_of_equivalent_configurations for s in unique_structures ]
Out[45]:
Because both substitutions were performed in a single step, number_of_equivalent_configurations
and full_configuration_degeneracy
now contain the same data:
In [46]:
[ s.full_configuration_degeneracy for s in unique_structures ]
Out[46]:
In [47]:
from bsym.interface.pymatgen import ( space_group_symbol_from_structure,
space_group_from_structure,
configuration_space_from_structure )
In [48]:
coords = np.array( [ [ 0.0, 0.0, 0.0 ],
[ 0.5, 0.5, 0.0 ],
[ 0.0, 0.5, 0.5 ],
[ 0.5, 0.0, 0.5 ] ] )
atom_list = [ 'Li' ] * len( coords )
lattice = Lattice.from_parameters( a=3.0, b=3.0, c=3.0, alpha=90, beta=90, gamma=90 )
structure = Structure( lattice, atom_list, coords )
In [49]:
space_group_symbol_from_structure( structure )
Out[49]:
In [50]:
space_group_from_structure( structure )
Out[50]:
In [51]:
configuration_space_from_structure( structure )
Out[51]:
bsym.ConfigurationSpace.unique_configurations()
and bsym.interface.pymatgen.unique_structure_substitutions()
both accept optional show_progress
arguments, which can be used to display progress bars (using tqdm
(https://tqdm.github.io).
Setting show_progress=True
will give a simple progress bar. If you are running bsym
in a Jupyter notebook, setting show_progress="notebook"
will give you a progress bar as a notebook widget.
(note, the widget status is not saved with this notebook, and may not display correctly on GitHub or using nbviewer)
In the example below, we find all unique configurations for the pseudo-ReO3 structured TiOF2 in a 2×2×2 supercell.
In [52]:
a = 3.798 # lattice parameter
coords = np.array( [ [ 0.0, 0.0, 0.0 ],
[ 0.5, 0.0, 0.0 ],
[ 0.0, 0.5, 0.0 ],
[ 0.0, 0.0, 0.5 ] ] )
atom_list = [ 'Ti', 'X', 'X', 'X' ]
lattice = Lattice.from_parameters( a=a, b=a, c=a, alpha=90, beta=90, gamma=90 )
unit_cell = Structure( lattice, atom_list, coords )
parent_structure = unit_cell * [ 2, 2, 2 ]
unique_structures = unique_structure_substitutions( parent_structure, 'X', { 'O':8, 'F':16 },
show_progress='notebook' )
In [53]:
%load_ext version_information
%version_information bsym, numpy, jupyter, pymatgen, tqdm
Out[53]: