Skip to content

Commit

Permalink
Add a test for a full silicon SCF workchain (#9)
Browse files Browse the repository at this point in the history
  • Loading branch information
Technici4n authored Nov 7, 2024
1 parent 53ef8e5 commit 7b7bf60
Show file tree
Hide file tree
Showing 7 changed files with 4,875 additions and 195 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:

strategy:
matrix:
python-version: ['3.8', '3.9', '3.10', '3.11']
python-version: ['3.9', '3.10', '3.11', '3.12']

services:
postgres:
Expand Down
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,22 @@ classifiers = [
'Framework :: AiiDA',
'License :: OSI Approved :: MIT License',
'Programming Language :: Python',
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11'
'Programming Language :: Python :: 3.11',
'Programming Language :: Python :: 3.12',
]
keywords = ['aiida', 'workflows']
requires-python = '>=3.8'
dependencies = [
'aiida_core[atomic_tools]>=2.0',
'aiida_core[atomic_tools]>=2.5',
'aiida-pseudo',
'click~=8.0',
'h5py',
'importlib_resources',
'jsonschema',
'numpy',
'packaging'
'packaging',
'pymatgen',
]

Expand Down
255 changes: 65 additions & 190 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,132 +1,36 @@
# -*- coding: utf-8 -*-
# pylint: disable=redefined-outer-name,too-many-statements
"""Initialise a text database and profile for pytest."""
import logging
import os

import pytest

pytest_plugins = ['aiida.manage.tests.pytest_fixtures'] # pylint: disable=invalid-name


@pytest.fixture(scope='session')
def filepath_tests():
"""Return the absolute filepath of the `tests` folder.
.. warning:: if this file moves with respect to the `tests` folder, the implementation should change.
:return: absolute filepath of `tests` folder which is the basepath for all test resources.
"""
return os.path.dirname(os.path.abspath(__file__))


@pytest.fixture
def filepath_fixtures(filepath_tests):
"""Return the absolute filepath to the directory containing the file `fixtures`."""
return os.path.join(filepath_tests, 'fixtures')


@pytest.fixture(scope='function')
def fixture_sandbox():
"""Return a `SandboxFolder`."""
from aiida.common.folders import SandboxFolder
with SandboxFolder() as folder:
yield folder


@pytest.fixture
def fixture_localhost(aiida_localhost):
"""Return a localhost `Computer`."""
localhost = aiida_localhost
localhost.set_default_mpiprocs_per_machine(1)
return localhost


@pytest.fixture
def fixture_code(fixture_localhost):
"""Return a ``Code`` instance configured to run calculations of given entry point on localhost ``Computer``."""

def _fixture_code(entry_point_name):
from aiida.common import exceptions
from aiida.orm import Code

label = f'test.{entry_point_name}'

try:
return Code.objects.get(label=label) # pylint: disable=no-member
except exceptions.NotExistent:
return Code(
label=label,
input_plugin_name=entry_point_name,
remote_computer_exec=[fixture_localhost, '/bin/true'],
)

return _fixture_code


@pytest.fixture
def serialize_builder():
"""Serialize the given process builder into a dictionary with nodes turned into their value representation.
:param builder: the process builder to serialize
:return: dictionary
"""

def serialize_data(data):
# pylint: disable=too-many-return-statements
from aiida.orm import BaseType, Code, Dict
from aiida.plugins import DataFactory

StructureData = DataFactory('core.structure')
UpfData = DataFactory('pseudo.upf')

if isinstance(data, dict):
return {key: serialize_data(value) for key, value in data.items()}

if isinstance(data, BaseType):
return data.value

if isinstance(data, Code):
return data.full_label
pytest_plugins = 'aiida.manage.tests.pytest_fixtures'

if isinstance(data, Dict):
return data.get_dict()
_LOGGER = logging.getLogger(__name__)
_julia_project_path = os.path.join(__file__, "..", "julia_environment")

if isinstance(data, StructureData):
return data.get_formula()

if isinstance(data, UpfData):
return f'{data.element}<md5={data.md5}>'
def pytest_sessionstart():
"""Instantiates the test Julia environment before any test runs."""
import subprocess

return data

def _serialize_builder(builder):
return serialize_data(builder._inputs(prune=True)) # pylint: disable=protected-access

return _serialize_builder
# Pkg.Registry.add() seems necessary for GitHub Actions
subprocess.run(['julia', f'--project={_julia_project_path}', '-e', 'using Pkg; Pkg.Registry.add(); Pkg.resolve(); Pkg.precompile();'], check=True)


@pytest.fixture
def generate_calc_job():
"""Fixture to construct a new `CalcJob` instance and call `prepare_for_submission` for testing `CalcJob` classes.
The fixture will return the `CalcInfo` returned by `prepare_for_submission` and the temporary folder that was passed
to it, into which the raw input files will have been written.
"""

def _generate_calc_job(folder, entry_point_name, inputs=None):
"""Fixture to generate a mock `CalcInfo` for testing calculation jobs."""
from aiida.engine.utils import instantiate_process
from aiida.manage.manager import get_manager
from aiida.plugins import CalculationFactory

manager = get_manager()
runner = manager.get_runner()

process_class = CalculationFactory(entry_point_name)
process = instantiate_process(runner, process_class, **inputs)

calc_info = process.prepare_for_submission(folder)

return calc_info

return _generate_calc_job
def get_dftk_code(aiida_local_code_factory):
"""Return an ``InstalledCode`` instance configured to run DFTK calculations on localhost."""

def _get_code():
return aiida_local_code_factory(
'dftk',
'julia',
label='dftk',
prepend_text=f"""\
export JULIA_PROJECT="{_julia_project_path}"
""",
)

return _get_code

@pytest.fixture
def generate_structure():
Expand All @@ -144,6 +48,12 @@ def _generate_structure(structure_id='silicon'):
structure = StructureData(cell=cell)
structure.append_atom(position=(0., 0., 0.), symbols='Si', name='Si')
structure.append_atom(position=(param / 4., param / 4., param / 4.), symbols='Si', name='Si')
elif structure_id == 'silicon_perturbed':
param = 5.43
cell = [[param / 2., param / 2., 0], [param / 2., 0, param / 2.], [0, param / 2., param / 2.]]
structure = StructureData(cell=cell)
structure.append_atom(position=(0.05, 0., 0.), symbols='Si', name='Si')
structure.append_atom(position=(param / 4., param / 4., param / 4.), symbols='Si', name='Si')
elif structure_id == 'water':
structure = StructureData(cell=[[5.29177209, 0., 0.], [0., 5.29177209, 0.], [0., 0., 5.29177209]])
structure.append_atom(position=[12.73464656, 16.7741411, 24.35076238], symbols='H', name='H')
Expand Down Expand Up @@ -177,90 +87,55 @@ def _generate_kpoints_mesh(npoints):

return _generate_kpoints_mesh


@pytest.fixture(scope='session')
def generate_parser():
"""Fixture to load a parser class for testing parsers."""

def _generate_parser(entry_point_name):
"""Fixture to load a parser class for testing parsers.
:param entry_point_name: entry point name of the parser class
:return: the `Parser` sub class
"""
from aiida.plugins import ParserFactory
return ParserFactory(entry_point_name)

return _generate_parser


# TODO: It would be nicer to automatically download the psp through aiida-pseudo
@pytest.fixture
def generate_remote_data():
"""Return a `RemoteData` node."""

def _generate_remote_data(computer, remote_path, entry_point_name=None):
"""Return a `KpointsData` with a mesh of npoints in each direction."""
from aiida.common.links import LinkType
from aiida.orm import CalcJobNode, RemoteData
from aiida.plugins.entry_point import format_entry_point_string

entry_point = format_entry_point_string('aiida.calculations', entry_point_name)
def load_psp():
"""Return the pd_nc_sr_pbe_standard_0.4.1_upf pseudopotential for an element"""

remote = RemoteData(remote_path=remote_path)
remote.computer = computer
def _load_psp(element: str):
from aiida import plugins
from pathlib import Path

if entry_point_name is not None:
creator = CalcJobNode(computer=computer, process_type=entry_point)
creator.set_option('resources', {'num_machines': 1, 'num_mpiprocs_per_machine': 1})
remote.base.links.add_incoming(creator, link_type=LinkType.CREATE, link_label='remote_folder')
creator.store()
if element != "Si":
raise ValueError("Only the Si psp is available for the moment.")

return remote
UpfData = plugins.DataFactory('pseudo.upf')
with open((Path(__file__) / ".." / "pseudos" / (element + ".upf")).resolve(), "rb") as stream:
return UpfData(stream)

return _generate_remote_data
return _load_psp


# TODO: Something like this should exist in aiida! We shouldn't have to do it ourselves just to capture why the test failed.
@pytest.fixture
def generate_bands_data():
"""Return a `BandsData` node."""

def _generate_bands_data():
"""Return a `BandsData` instance with some basic `kpoints` and `bands` arrays."""
from aiida.plugins import DataFactory
import numpy
BandsData = DataFactory('core.array.bands') #pylint: disable=invalid-name
bands_data = BandsData()

bands_data.set_kpoints(numpy.array([[0., 0., 0.], [0.625, 0.25, 0.625]]))

bands_data.set_bands(
numpy.array([[-5.64024889, 6.66929678, 6.66929678, 6.66929678, 8.91047649],
[-1.71354964, -0.74425095, 1.82242466, 3.98697455, 7.37979746]]),
units='eV'
)
def submit_and_await_success(submit_and_await):
"""
Submits a process or process builder to the engine.
Validates that the process succeeds, or logs a report if it doesn't.
"""

return bands_data
def _submit_and_await_success(*args, **kwargs):
from aiida.cmdline.utils.common import get_calcjob_report, get_workchain_report
from aiida.orm.nodes.process import CalcJobNode, WorkChainNode

return _generate_bands_data
result = submit_and_await(*args, **kwargs)

if result.exit_status != 0:
if isinstance(result, CalcJobNode):
_LOGGER.warning("Report of CalcJobNode:")
_LOGGER.warning(get_calcjob_report(result))
elif isinstance(result, WorkChainNode):
_LOGGER.warning("Report of WorkChainNode:")
_LOGGER.warning(get_workchain_report(result, "REPORT"))

@pytest.fixture
def generate_workchain():
"""Generate an instance of a `WorkChain`."""

def _generate_workchain(entry_point, inputs):
"""Generate an instance of a `WorkChain` with the given entry point and inputs.
:param entry_point: entry point name of the work chain subclass.
:param inputs: inputs to be passed to process construction.
:return: a `WorkChain` instance.
"""
from aiida.engine.utils import instantiate_process
from aiida.manage.manager import get_manager
from aiida.plugins import WorkflowFactory
# Also log reports of all child calcjobs:
for link in result.base.links.get_outgoing(CalcJobNode):
if isinstance(link.node, CalcJobNode):
_LOGGER.warning("Report of child CalcJobNode:")
_LOGGER.warning(get_calcjob_report(link.node))

process_class = WorkflowFactory(entry_point)
runner = get_manager().get_runner()
process = instantiate_process(runner, process_class, **inputs)
assert result.exit_status == 0

return process
return result

return _generate_workchain
return _submit_and_await_success
2 changes: 2 additions & 0 deletions tests/julia_environment/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
LocalPreferences.toml
Manifest.toml
5 changes: 5 additions & 0 deletions tests/julia_environment/Project.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[deps]
AiidaDFTK = "26386dbc-b74b-4d9a-b75a-41d28ada84fc"

[compat]
AiidaDFTK = "0.1"
Loading

0 comments on commit 7b7bf60

Please sign in to comment.