How to create AddOns for VNL

Version: 2015

An AddOn is a Python module that contains one or more plugins for VNL. They
are used to add new functionality to the software. There are several types of
plugins available. This tutorial will focus on the types of plugins that allows
VNL to read and write new data formats. This tutorial contains three examples.
The first is a plugin to read molecular configurations from XYZ files. The
second is a plugin to read electron densities.
logo

Basic Structure of a AddOn Module

Because plugins are contained in Python modules, they should exist in their own directory. As an example we will take a look at an plugin that reads XYZ files. Its directory structure looks like:

XYZFilters/
    __init__.py
    XYZLabFloor.py
    XYZFileRawReader.py

The __init__.py file is a special file that tells Python that this folder is a module. It also contains code that is executed when the module is imported. For a plugin, this file also needs to contain some information to inform VNL about itself.

Example 1: A Plugin to Read XYZ Files

The __init__.py file in the XYZFilters AddOn contains the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import datetime
import XYZLabFloor

__addon_description__ = "Plugins for importing and exporting XYZ configurations."
__addon_version__ = "1.0"
__addon_date__ = datetime.date(year=2015, month=8, day=6)

__plugins__ = [XYZLabFloor.XYZLabFloor]

def reloadPlugins():
    reload(XYZLabFloor)

This code gives a description of the AddOn, assigns a version number, and a date that the AddOn was last updated. It also defines a list of plugins that this AddOns provides. In this case, there is a XYZLabFloor.py file that contains a plugin class named XYZLabFloor. Additionally there is a function named reloadPlugins that is provided so that VNL may reload the plugin if the source code files change.

We will now look at the structure of the XYZLabFloor.py file that contains the plugin class. At the top of the file we import the modules and classes we need to write the plugin:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import os

from API import LabFloorImporterPlugin
from API import LabFloorItem
from NL.CommonConcepts.Configurations.MoleculeConfiguration import MoleculeConfiguration
from NL.CommonConcepts.PeriodicTable import SYMBOL_TO_ELEMENT
from NL.CommonConcepts.PhysicalQuantity import Angstrom
from NL.ComputerScienceUtilities import Exceptions

from XYZFileRawReader import XYZFileRawReader

Next we need to define the plugin class. Plugins must inherit from a particular class defined in ATK. In this case, we will define a class that inherits from LabFloorImporterPlugin:

13
class XYZLabFloor(LabFloorImporterPlugin):

This type of plugin must define two methods. The first method is scan. The role of this method is to determine if a particular file is handled by this plugin and if so what type of LabFloor object(s) the file contains. It will return a list of items to the LabFloor. The second method is load, which is responsible for parsing the file and loading the object into memory.

For our XYZLabFloor plugin the scan method is defined as:

18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
    def scan(self, filename):
        """
        Scans a file to check if it is supported by the plugin

        @param filename : The path to be scanned.
        @type           : string

        @return A list of LabFloorItems
        """
        # Setup a resulting vector.
        result = []

        # Determine extension
        basename = os.path.basename(filename)
        no_extension_name, extension = os.path.splitext(basename)

        # Return empty string if extension isn't ".xyz"
        if extension != '.xyz':
            return result

        # Try to load configuration
        try:
            reader = XYZFileRawReader(filename)
        except Exception:
            return result

        for molecule_idx in xrange(reader.numOfMolecules()):

            # Read the comment for this molecule.
            comment = reader.comment(molecule=molecule_idx)

            # Create and add LabFloorItem to list
            if reader.numOfMolecules() == 1:
                title = no_extension_name
            else:
                title = no_extension_name + " (" + str(molecule_idx) + ")"

            # Create labfloor item.
            item = LabFloorItem(MoleculeConfiguration,
                                title=title,
                                tool_tip=comment,
                                molecule_idx=molecule_idx)

            # Add to result list.
            result.append(item)

        # Return the result list.
        return result

This code detects if the file is a valid XYZ file by first testing if the filename has an “xyz” extension and then trying to actually parse the file. If the file is not a valid XYZ file, then an empty list is returned. If it is a valid file, then each of the molecules contained in the file are read in and a LabFloorItem is created for each molecule.

A LabFloorItem is a class that represents the item on the LabFloor. It contains the type of object, in this case it is a MoleculeConfiguration as well as a title and tool tip (text that is visible when the mouse cursor hovers on the item). Extra information about the item can also be passed as keyword argument. As we will see next, these arguments get passed to the load method.

The load method is called by VNL when the user interacts with the item. For example, this occurs when visualizing the structure with the viewer or importing it to the builder. The load method is defined as:

67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
    def load(self, filename, molecule_idx=0):
        """
        Load the desired object in memory.

        @param filename : The path of the XYZ-file.
        @type           : string

        @return Desired object (MoleculeConfiguration)
        """
        # Read the file
        reader = XYZFileRawReader(filename)

        # Lists of elements and positions.
        elements = []
        positions = []

        # Loop over atoms.
        for atom in reader.atomList(molecule=molecule_idx):
            elements.append(SYMBOL_TO_ELEMENT[atom["element"]])
            positions.append(atom["coords"]*Angstrom)

        # Create configuration.
        configuration = MoleculeConfiguration(
            elements=elements,
            cartesian_coordinates=positions)

        return configuration

The method reads in the file contents and extracts the elements and positions of the requested molecule. The method is passed the index of the molecule to read (this was stored in the LabFloorItem that was created in the scan method) and creates a MoleculeConfiguration. The MoleculeConfiguration class is how ATK represents molecules. For periodic systems there is a corresponding BulkConfiguration class that stores the lattice vectors along with the coordinates and elements.

The full source code for this AddOn can be downloaded.

Example 2: Plugin to export configurations

In this example, we will write a plugin to allow VNL to export configurations to XYZ files. The directory structure for the module will look like:

XYZExporter/
    __init__.py
    XYZExporter.py

The first step is to write the __init__.py file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import datetime
import XYZExporterPlugin

__addon_description__ = "Plugins for exporting XYZ files."
__addon_version__ = "1.0"
__addon_date__ = datetime.date(year=2015, month=8, day=6)

__plugins__ = [XYZExporterPlugin.XYZExporterPlugin]

def reloadPlugins():
    reload(XYZExporterPlugin)

The next step will be to write the actual plugin class. In the previous example, the plugin class was derived from the LabFloorImporterPlugin class to indicate that it is a plugin for importing data. This plugin, however, will derive from ExportConfigurationPlugin. These plugins are used to extend the number of export formats supported by the builder. With the builder open, you can choose File->Export to see a list of the file formats that are supported.

Classes that inherit from ExportConfigurationPlugin must implement four methods: title, extension, canExport, and export. The title method needs to return the name of this type of file (“XYZ”). The extension method should return the file extension (“xyz”). The canExport method determines if the type of configuration (MoleculeConfiguration, BulkConfiguration, DeviceConfiguration, or NudgedElasticBand) are supported by this plugin (XYZ files only support MoleculeConfiguration objects). Finally, the export method is where the configuration is passed in and written out to disk.

Let’s look at the code for our XYZExporterPlugin in the XYZExporterPlugin.py file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
from NL.CommonConcepts.Configurations.MoleculeConfiguration import MoleculeConfiguration
from NL.CommonConcepts.PhysicalQuantity import Angstrom

from API import ExportConfigurationPlugin, showMessage

class XYZExporterPlugin(ExportConfigurationPlugin):
    """ Class for handling the export of the XYZ input files. """

    def title(self):
        """ Return the title file selection dialog. """

        return 'XYZ'

    def extension(self):
        """ The default extension of XYZ. """

        return 'xyz'

    def export(self, configuration, path):
        """
        Export the configuration.

        @param configuration : The configuration to export.
        @param path          : The path to save the configuration to.

        @return None
        """

        # XYZ files only supports molecules.
        if not isinstance(configuration, MoleculeConfiguration):
            showMessage('XYZExporter can only export MoleculeConfigurations')
            return

        # Open the file with write permission.
        with open(path, 'w') as f:

            # Get the total number of atoms.
            number_of_atoms = len(configuration)

            # Write out the header to the file.
            f.write('%i\n' % number_of_atoms)
            f.write('Generated by XYZExporter\n')

            # Get the list of atomic symbols.
            symbols = [ element.symbol() for element in configuration.elements() ]
            # Get the cartesian coordinates in units of Angstrom.
            coordinates = configuration.cartesianCoordinates().inUnitsOf(Angstrom)

            # Loop over each atom and write out its symbol and coordinates.
            for i in xrange(number_of_atoms):
                x, y, z = coordinates[i]
                f.write('%3s %16.8f %16.8f %16.8f\n' % (symbols[i], x, y, z))


    def canExport(self, configuration):
        """
        Method to determine if an exporter class can export a given configuration.

        @param configuration : The configuration to test.

        @return A bool, True if the plugin can export, False if it cannot.
        """

        supported_configurations = [MoleculeConfiguration]

        return isinstance(configuration, supported_configurations)

The full source code for this AddOn can be downloaded.

Example 3: Plugin to read electron densities

For this example we will make a new plugin that reads electron density data. For this tutorial we will define a new format for 3D grid data that is stored in a NumPy’s binary .npz files.

Construct a density

This code will define a electron density and save it to the file electron_density.npz. This will be the density file that our plugin will read.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import numpy

# Define a orthogonal cell.
cell = numpy.array( [ [ 5.0, 0.0, 0.0 ],
                      [ 0.0, 5.0, 0.0 ],
                      [ 0.0, 0.0, 5.0 ] ] )
# Create a 3D grid of points from 0 to 5 in x, y, and z.
x = numpy.linspace(0.0, 5.0)
y = numpy.linspace(0.0, 5.0)
z = numpy.linspace(0.0, 5.0)
xx, yy, zz = numpy.meshgrid(x, y, z, indexing='ij')

# Define an electron density as a Gaussian centered at (2.5, 2.5, 2.5) times a
# sine wave in the x direction.
density = numpy.exp(-(xx-2.5)**2 - (yy-2.5)**2 - (zz-2.5)**2) * numpy.sin(yy-2.5)

# Save the cell and density to a .npz file.
numpy.savez('electron_density.npz', cell=cell, density=density)

This electron density is not physical since it will be negative in some places, but it will allow us to easily double check that the data is read in correctly. The source code to this script can downloaded.

Write the NPZFilters AddOn

The directory structure for this AddOn will look like:

NPZFilters/
    __init__.py
    NPZLabFloor.py

Like the previous example, we will first focus on the __init__.py file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import datetime
import NPZLabFloor

__addon_description__ = "Plugin for reading a NPZ formatted electron density."
__addon_version__ = "1.0"
__addon_date__ = datetime.date(2014, 9, 1)

__plugins__ = [NPZLabFloor.NPZLabFloor]

def reloadPlugins():
    reload(NPZLabFloor)

There is nothing new here. Now we need to define the actual plugin class:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import numpy
import os

import NLEngine

from API import LabFloorImporterPlugin
from API import LabFloorItem
from NL.Analysis.ElectronDensity import ElectronDensity
from NL.Analysis.GridValues import GridValues
from NL.ComputerScienceUtilities.NLFlag import Spin
from NL.CommonConcepts.PhysicalQuantity import Angstrom

class NPZLabFloor(LabFloorImporterPlugin):
    """
    Class for handling the importing of NPZ-files as LabFloor items.
    """

    def scan(self, filename):
        """
        Scans a file to check if it is supported by the plugin

        @param filename : The path to be scanned.
        @type           : string

        @return A list of LabFloorItems
        """
        # Setup a vector for the LabFloorItems that will be returned.
        lab_floor_items = []

        # Determine extension
        basename = os.path.basename(filename)
        no_extension_name, extension = os.path.splitext(basename)

        # Return an empty list if the extension isn't ".npz"
        if extension != '.npz':
            return []

        item = LabFloorItem(ElectronDensity,
                            title='NPZ Electron Density',
                            tool_tip='NPZ Electron Density')

        # Add to the list of items.
        lab_floor_items.append(item)

        # Return the list of items.
        return lab_floor_items

    def load(self, filename):
        """
        Load the desired object in memory.

        @param filename : The path of the NPZ-file.
        @type           : string

        @return Desired object (MoleculeConfiguration)
        """

        # Read the file
        npz = numpy.load(filename)

        # Create an "empty" ElectronDensity object.
        electron_density = ElectronDensity.__new__(ElectronDensity)

        # We will now fill out a dictionary that contains the information
        # needed by the ElectronDensity class.
        data = {}

        # The "data" key is the electron density. The units must be given in the
        # "data_unit" key. The array should have have the x-axis as the first
        # dimension, the y-axis as the second, and the z-axis as the third.
        data['data'] = npz['density']

        # The data in "data" has no units so they are assigned here.
        data['data_unit'] = 1.0 * Angstrom**-3

        # Set the origin to be at zero.
        data['origo'] = numpy.zeros(3)

        # The cell must be given in Bohr units.
        data['cell'] = npz['cell']

        # The boundary conditions are expressed as a list of 6 numbers that should
        # map to:
        # { Dirichlet, Neumann, Periodic, Multipole };
        # A value of 2 corresponds to "Periodic".
        data['boundary_conditions'] = [2, 2, 2, 2, 2, 2]

        # Construct the GridValues specific part of the object.
        GridValues._populateFromDict(electron_density, data)

        # Set the spin_type to unpolarized.
        spin_type = NLEngine.UNPOLARIZED
        electron_density._AnalysisSpin__spin_type = spin_type

        sp = Spin.All
        electron_density._AnalysisSpin__spin = sp
        electron_density._setSupportedSpins(sp)

        return electron_density

The full source code for this AddOn can be downloaded.

How to install AddOns

There are two different ways to install AddOns. The first way is to set the environment variable QUANTUM_ADDONS_PATH to a directory where the AddOn modules are located. For example if the path to the NPZFilters AddOn from the previous section is $HOME/AddOns/NPZFilters then setting the environment variable QUANTUM_ADDONS_PATH=$HOME/AddOns, would be correct.

Another way is to zip the Python module and install it through the graphical interface. The first step is to create a zip file containing the module. Following the NPZFilter example in the last paragraph, this can be done by running zip -r NPZFilters.zip $HOME/AddOns/NPZFilters. Then, in VNL, under the Help menu is an item named AddOn Manager. Opening the AddOn manager will present a window that should look like the following:

../../_images/AddOnManager.png

By clicking Local Install, you will be presented with a file dialog. After selecting NPZFilters.zip VNL will install the AddOn to the QUANTUM_ADDONS_PATH.

Test the NPZFilters AddOn

After following the steps in the previous section, the NPZFilters AddOn should now be installed. You can double check this by pulling up the AddOn Manager and seeing that NPZFilters is listed. If we create a new project in VNL and the folder contains the electron_density.npz file we created then an ElectronDensity object should show up on the lab floor.

../../_images/NPZLabFloor.png

Click on the Viewer... button in the panel on the right to visualize the electron density. This will present a dialog to choose between an isosurface and a cut plane. Choose isosurface. The default isosurface value is the average charge density, which is zero for our charge density (since half is negative and half is positive). Click on the Properties... button in the panel on the right and drag the Isovalue... slider to a value near 1.

../../_images/Isovalue.png

The resulting isosurface should now look like dumbbell, with the two different colors representing the areas of negative and positive density and a plane of zero density through the x-z axis. This confirms that we correctly read in our model density function.

../../_images/Isosurface.png