Code your own polarizability tensor workflow, part 2¶
The main goal of this notebook is to show how the code implemented in the previous exercise can be easily converted to a fully functional class deriving from the AbstractWorkflow
class.
We will stick to the polarizability tensor problem, in order to build on what was achieved in the previous exercises. The structure of this notebook is very similar to the previous ones. You can use the same resources to help you (especially the), while the solution to this exercise can be found here.
The AbstractWorkflow class¶
You already saw that using the Workflow
class allows to get a clean separation of the procedure to compute a quantity of interest:
first, the definition of the jobs to be run,
then the jobs are run,
and finally, the desired output can be computed from the results of the calculations.
The goal of the AbstractWorkflow
class is to ease the implementation of this type of workflow in a class that is meant to be general, i.e., that can be applicable to a large class of systems, using a large set of possible input parameters. Such a class is meant to be used simply by:
initializing an instance of the class, setting the queue of jobs automatically,
running all the jobs of the workflow instance and performing the post-processing, all these actions being done by applying the
run
method.
And that’s it!
Note that the post-processing procedure, which is defined in the class body, is automatically performed after all the jobs in the workflow queue. The quantities of interest (here, the polarizability tensor) are even used to set semi-private attributes, so that they can be easily accessed afterwards (don’t worry, the last sentence should get clearer by the end of the notebook).
Another interest of writing a class is that it enables you to add more functionalities to your class afterwards, such as adding another quantity to compute while running the post-processing procedure. Such changes would be readily available in your scripts or notebooks where you used that class: no need to modify those files one by one to make that functionality available there.
To write such a class you will need to:
define which are the quantities to be computed while post-processing the results by:
setting the value of the
POST_PROCESSING_ATTRIBUTES
list, containing the names of all the quantities of interest computed while post-processing,defining a property getter for each of these attributes, so as to access them afterwards,
override the
__init__
method, in order to make sure than the queue of jobs is correctly initialized,define the
post_proc
method to compute and then set the values of the quantities of interest.
Create a class¶
The use of the Workflow
class in the previous notebook showed you that having a clear separation of the initialization and post-processing of a workflow is important, notably to reduce the amount of code to copy and paste each time you want to change the system or the input parameters. We will go even further in that direction by writing a class deriving from the AbstractWorkflow
class. You will see that the code you implemented in the previous exercise can be quickly adapted to meet our
needs here.
Part of the body of the class is already written, you must only code the following:
add the string
"pol_tensor"
to the POST_PROCESSING_ATTRIBUTES list,initialize the queue of jobs in the
__init__
method,define the
post_proc
method to compute the value of thepol_tensor
attribute.
The __init__
method has ground_state
as argument, which is meant to be a Job
instance of the reference ground state. This job must be the first one in the queue. The three other jobs must be added by taking this one job as reference, only modifying the run directory (in the same manner as in the previous exercise) and the input parameters, so as to apply an electric field in each direction (\(x\), \(y\) and \(z\)). To that end, you must add an optional argument
ef_amplitude
to the __init__
method to state the value of the electric field amplitude to be used in all the jobs (default value: 1.e-4
).
The definition of the pol_tensor
method is given so as to give you an example of how to actually code such methods. Note the use of a _poltensor
attribute in that method and in the post_proc
method; this is what we meant by “semi-private attributes” in the previous section: defining a property returning such a semi-private attribute ensures that you can access the pol_tensor
attribute here, but it cannot be set. The only way of modifying the value of this attribute is to change
the value of the _poltensor
. This is actually what is done in the post_proc
method!
[ ]:
import numpy as np
# Do not forget to import some relevant stuff...
from mybigdft.workflows.workflow import AbstractWorkflow
class PolTensor(AbstractWorkflow):
"""
This workflow allows to compute the (electronic) polarizability
tensor of a given system.
The polarizability tensor represents the response of the charges of
a system (its dipole) to the application of an external electric
field.
To compute this polarizability tensor, some BigDFT calculations are
performed, where the system is subject to an external electric
field along each direction of space (:math:`x`, :math:`y` and
:math:`z`). The elements of the polarizability tensor are then
defined by the ratio of the delta of the dipole in one direction and
the delta of the electric field amplitudes:
.. math::
\alpha_{ij} = \frac{\Delta D_i}{\Delta E_j}
where :math:`i, j \in \{x, y, z\}`, :math:`\Delta D_i` is the
variation of the dipole along the :math:`i` direction and
:math:`\Delta E_j` is the variation of the electric field amplitude
along the :math:`j` direction.
"""
# Add "pol_tensor" to the list
POST_PROCESSING_ATTRIBUTES = []
# Add ef_amplitude optional argument (default value: 1.e-4)
def __init__(self, ground_state, ):
"""
A PolTensor workflow is initialized by the job of the
ground-state of the system and an electric field amplitude.
Parameters
----------
ground_state: Job
Reference job, specifying the base input parameters (where no
electric field is applied), the geometry of the system and the
base run directory of the calculations.
ef_amplitude: float
Electric field amplitude to be applied along each direction,
in atomic units (default to 1.e-4).
"""
self._ground_state = ground_state
# Set the queue of jobs thanks to ground_state and ef_amplitude
pt_queue = []
# This calls the __init__ method of the AbstractWorkflow class to
# set the queue, the run method (that calls the post_proc method
# automatically) and the default value of the post-processing
# attributes to None.
super().__init__(queue=pt_queue)
@property
def ground_state(self):
"""
Set ground_state as a semi-private attribute: it can be accessed,
but it cannot be set.
Returns
-------
Job
Reference job, where no electric field is applied.
"""
return self._ground_state
@property
def pol_tensor(self):
"""
Set pol_tensor as a semi-private attribute: it can be accessed,
but it cannot be set.
Returns
-------
numpy array of shape (3, 3)
Polarizability tensor of the system, using :attr:`ground_state`
as a reference job.
"""
return self._pol_tensor
def post_proc(self):
"""
Compute and set the polarizability tensor of the system.
"""
pol_tensor = np.zeros((3, 3))
# Compute the polarizability tensor thanks to the jobs in the queue
self._pol_tensor = pol_tensor
[ ]:
# Let us test that class with for the N2 molecule
from mybigdft import Job, Posinp, Atom, InputParams
atoms = [Atom('N', [0, 0, 0]), Atom('N', [0, 0, 1.0935])]
pos = Posinp(atoms, units="angstroem", boundary_conditions="free")
inp = InputParams({"dft": {"rmult": [7, 9], "hgrids": 0.35}})
# 1- Using non-default electric field amplitude:
gs_1 = Job(posinp=pos, inputparams=inp, name="N2",
run_dir="N2/pol_tensor/non_default/")
pt_wf_1 = PolTensor(gs_1, ef_amplitude=5.e-4)
# - correct electric field
expected_efs_1 = [None, [5.e-4, 0, 0], [0, 5.e-4, 0], [0, 0, 5.e-4]]
for job, ef in zip(pt_wf_1.queue, expected_efs_1):
assert job.inputparams["dft"].get("elecfield") == ef
# - correct run directories
expected_run_dirs_1 = ["non_default/", "non_default/EF_along_x+",
"non_default/EF_along_y+", "non_default/EF_along_z+"]
for job, run_dir in zip(pt_wf_1.queue, expected_run_dirs_1):
assert job.run_dir.endswith(run_dir)
# - correct pol_tensor attribute
assert pt_wf_1.pol_tensor is None
try:
pt_wf_1.pol_tensor = np.zeros((3, 3))
except AttributeError as e:
assert repr(e) == """AttributeError("can't set attribute",)"""
print("Correct error catched!")
# 2- Using default electric field amplitude:
gs_2 = Job(posinp=pos, inputparams=inp, name="N2",
run_dir="N2/pol_tensor/default/")
pt_wf_2 = PolTensor(gs_2)
# - correct electric field
expected_efs_2 = [None, [1.e-4, 0, 0], [0, 1.e-4, 0], [0, 0, 1.e-4]]
for job, ef in zip(pt_wf_2.queue, expected_efs_2):
assert job.inputparams["dft"].get("elecfield") == ef
# - correct run directories
expected_run_dirs_2 = ["default/", "default/EF_along_x+",
"default/EF_along_y+", "default/EF_along_z+"]
for job, run_dir in zip(pt_wf_2.queue, expected_run_dirs_2):
assert job.run_dir.endswith(run_dir)
# - correct pol_tensor attribute
assert pt_wf_2.pol_tensor is None
try:
pt_wf_2.pol_tensor = np.zeros((3, 3))
except AttributeError as e:
assert repr(e) == """AttributeError("can't set attribute",)"""
print("Correct error catched!")
Use that class!¶
The PolTensor
class now being implemented, you can use it in order to (effortlessly) study some problems. Given the implementation of that class, you can solve some problems in the most concise manner, especially compared to what was possible if you were to use the schemes presented in the previous notebooks.
The polarizability tensor must not depend on the rotation of the initial molecule
The atoms of the molecule here lie along the \(z\) axis and the results should not change if the atoms lie along the \(x\) or \(y\) axis, for instance. Use the default electric field amplitude.
[ ]:
# Compute the polarizability tensor when the molecule is along the x axis
atoms_x = []
pos_x = Posinp(atoms_x, )
run_dir_x = "N2/pol_tensor/atoms_along_x/"
pt_wf_x =
pt_wf_x.run()
# Compute the polarizability tensor when the molecule is along the x axis
atoms_y = []
pos_y = Posinp(atoms_y, )
run_dir_y = "N2/pol_tensor/atoms_along_y/"
pt_wf_y =
pt_wf_y.run()
[ ]:
# Let us ensure that you obtained the expected results:
# - both tensors are the same
assert np.allclose(pt_wf_x.pol_tensor, pt_wf_y.pol_tensor)
# - they have the same trace as the polarizability tensor where the molecule
# is along the z axis
gs_z = Job(posinp=pos, inputparams=inp, name="N2", run_dir="N2/pol_tensor/")
pt_wf_z = PolTensor(gs_z)
pt_wf_z.run()
print(pt_wf_z.pol_tensor)
assert np.isclose(np.trace(pt_wf_x.pol_tensor), np.trace(pt_wf_z.pol_tensor))
What is the influence of the electric field amplitude on the results?
Compute the polarizability tensor of the N\(_2\) molecule using various electric field amplitudes and comment on how this affects the value of polarizability tensor.
[ ]:
ef_amplitudes = [1.e-11, 1.e-10, 1.e-9, 1.e-8, 1.e-7, 1.e-6, 1.e-5, 1.e-4, 1.e-3, 1.e-2, 1.e-1]
# Loop over the electric field amplitudes to compute various
# polarizability tensors
How does the polarizability tensor depend on the system geometry?
Modify the structure of the system by varying the distance between both atoms and comment on how this affects the value of polarizability tensor. Use the default electric field amplitude and the same base input parameters.
[ ]:
distances = [1.0735, 1.0835, 1.0935, 1.1035, 1.1135]
# Loop over the interatomic distances to compute various
# polarizability tensors
Improve that class!¶
Another interest of defining a class is that you can easily improve it. You then only have to implement minor changes to your already performed scripts or notebooks to see those changes.
For instance, you often find the mean polarizability in the literature instead of the tensor itself. It is defined as the mean value of the polarizability tensor diagonal elements. Given that you already compute that tensor in the post-processing procedure, the addition of another post-processing attribute named mean_polarizability
seems like a good idea.
Add the mean polarizability post-processing attribute
The goal here is to compute the mean polarizability while post-processing the calculations and make it available via an attribute. You therefore have to perform three changes to your class:
add
"mean_polarizability"
toPOST_PROCESSING_ATTRIBUTES
define the
mean_polarizability
attribute as a property (hint: usepol_tensor
as a template).compute the mean polarizabilty in the
post_proc
method (hint: the sum of the diagonal elements of a tensor can be easily computed with numpy: simply usenp.trace()
) and set themean_polarizability
attribute via its private counterpart (hint: again, use what is done forpol_tensor
as a template).
Once you are happy with your changes, run the whole notebook (click on “Restart Kernel and run all cells” under the “Kernel” tab). You should see that you did not need to modify any cell above to run the tests below. This is a reason why it is interesting to work with classes.
[ ]:
assert np.isclose(pt_wf_z.mean_polarizability, 12.25712)
assert pt_wf_x.mean_polarizability == pt_wf_y.mean_polarizability == pt_wf_z.mean_polarizability
Further step (optional)
You could add the possibility to perform two BigDFT calculations per space coordinates in order to get more accurate results, while removing the reference job without any electric field. This means that six jobs must be run instead of four: two per space coordinate - one with a positive electric field amplitude, the other with a negative one (the run directory in that case must end by x-
if the electric field is along the \(x\) direction). This is especially relevant when the system
under consideration has no dipole: BigDFT would still find a residual dipole, that might be large enough to give messy results (depending on the required accuracy).
This exercise is optional as it might be a bit tedious for beginners. The main difficulty is to keep the ability of running only four jobs to get the polarizability tensor and the mean polarizability: you will have to add an argument order
to the __init__
method and make sure it triggers to correct behavior:
if
order=1
, then four jobs should be performed,if
order=2
, then six jobs should be performed.
This means you must make sure that the initialization of the queue depends on that parameter, ans so must be the computation of the polarizability tensor at the post-processing level.
For those interested in doing it, you may want to use the same scheme as above: first use the Workflow class to set up the workflow using six different calculations before actually implementing that into the PolTensor
class. You might even want to translate the workflow with six jobs to a new class deriving from AbstractWorkflow
before actually modifying the PolTensor
class. This should allow you to see which part of the code you have to modify, and to do it in a minimal fashion.