From c9700eef984da15c66286f6ac35218ba5d174c0a Mon Sep 17 00:00:00 2001 From: Federica Zanca <93498393+federicazanca@users.noreply.github.com> Date: Fri, 15 Mar 2024 15:32:00 +0000 Subject: [PATCH] single point calcjob (#84) add singlepoint calcuations for aiida and model management --------- Co-authored-by: Alin Marin Elena Co-authored-by: ElliottKasoar <45317199+ElliottKasoar@users.noreply.github.com> Co-authored-by: Jacob Wilkins <46597752+oerc0122@users.noreply.github.com> --- .github/workflows/ci.yml | 5 +- README.md | 100 +++---- aiida_mlip/__init__.py | 4 +- aiida_mlip/calculations.py | 93 ------- aiida_mlip/calculations/__init__.py | 3 + aiida_mlip/calculations/singlepoint.py | 263 ++++++++++++++++++ aiida_mlip/cli.py | 62 ----- aiida_mlip/data/__init__.py | 93 +------ aiida_mlip/data/model.py | 109 ++++++-- aiida_mlip/helpers.py | 99 ------- aiida_mlip/parsers.py | 110 ++++++-- conftest.py | 16 -- .../source/apidoc/aiida_mlip.calculations.rst | 25 ++ docs/source/apidoc/aiida_mlip.rst | 31 +-- docs/source/conf.py | 4 + docs/source/user_guide/calculations.rst | 35 +++ docs/source/user_guide/{api.rst => data.rst} | 0 docs/source/user_guide/get_started.rst | 4 +- docs/source/user_guide/index.rst | 3 +- examples/calculations/submit_singlepoint.py | 170 +++++++++++ examples/example_01.py | 72 ----- examples/input_files/__init__.py | 0 examples/input_files/file1.txt | 2 - examples/input_files/file2.txt | 2 - pyproject.toml | 11 +- tests/calculations/test_singlepoint.py | 136 +++++++++ tests/conftest.py | 211 ++++++++++++++ tests/data/test_model.py | 2 + tests/input_files/__init__.py | 0 tests/input_files/file1.txt | 2 - tests/input_files/file2.txt | 2 - tests/test_calculations.py | 38 --- tests/test_cli.py | 38 --- tox.ini | 5 +- 34 files changed, 1089 insertions(+), 661 deletions(-) delete mode 100644 aiida_mlip/calculations.py create mode 100644 aiida_mlip/calculations/__init__.py create mode 100644 aiida_mlip/calculations/singlepoint.py delete mode 100644 aiida_mlip/cli.py delete mode 100644 aiida_mlip/helpers.py delete mode 100644 conftest.py create mode 100644 docs/source/apidoc/aiida_mlip.calculations.rst create mode 100644 docs/source/user_guide/calculations.rst rename docs/source/user_guide/{api.rst => data.rst} (100%) create mode 100644 examples/calculations/submit_singlepoint.py delete mode 100644 examples/example_01.py delete mode 100644 examples/input_files/__init__.py delete mode 100644 examples/input_files/file1.txt delete mode 100644 examples/input_files/file2.txt create mode 100644 tests/calculations/test_singlepoint.py create mode 100644 tests/conftest.py delete mode 100644 tests/input_files/__init__.py delete mode 100644 tests/input_files/file1.txt delete mode 100644 tests/input_files/file2.txt delete mode 100644 tests/test_calculations.py delete mode 100644 tests/test_cli.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e91e9215..4fc35a9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,11 +46,14 @@ jobs: run: | poetry env use ${{ matrix.python-version }} poetry install --with dev + echo "JANUS_PATH=$(poetry run which janus)" >> $GITHUB_ENV - name: Run test suite + env: - # show timings of tests + # show timings of test PYTEST_ADDOPTS: "--durations=0" + JANUS_PATH: ${{ env.JANUS_PATH }} run: poetry run pytest --cov aiida_mlip --cov-append . - name: Report coverage to Coveralls diff --git a/README.md b/README.md index e9496bd7..ff228734 100644 --- a/README.md +++ b/README.md @@ -18,67 +18,59 @@ intended to help developers get started with their AiiDA plugins. * [`.github/`](.github/): [Github Actions](https://github.com/features/actions) configuration * [`ci.yml`](.github/workflows/ci.yml): runs tests, checks test coverage and builds documentation at every new commit * [`publish-on-pypi.yml`](.github/workflows/publish-on-pypi.yml): automatically deploy git tags to PyPI - just generate a [PyPI API token](https://pypi.org/help/#apitoken) for your PyPI account and add it to the `pypi_token` secret of your github repository + * [`docs.yml`](.github/workflows/docs.yml): builds and deploys the documentation * [`aiida_mlip/`](aiida_mlip/): The main source code of the plugin package - * [`data/`](aiida_mlip/data/): A new `DiffParameters` data class, used as input to the `DiffCalculation` `CalcJob` class - * [`calculations.py`](aiida_mlip/calculations.py): A new `DiffCalculation` `CalcJob` class - * [`cli.py`](aiida_mlip/cli.py): Extensions of the `verdi data` command line interface for the `DiffParameters` class - * [`helpers.py`](aiida_mlip/helpers.py): Helpers for setting up an AiiDA code for `diff` automatically - * [`parsers.py`](aiida_mlip/parsers.py): A new `Parser` for the `DiffCalculation` -* [`docs/`](docs/): A documentation template ready for publication on [Read the Docs](http://aiida-diff.readthedocs.io/en/latest/) -* [`examples/`](examples/): An example of how to submit a calculation using this plugin + * [`data/`](aiida_mlip/data/): Plugin `Data` classes + * [`model.py/`](aiida_mlip/data/model.py) `ModelData` class to save mlip models as AiiDA data types + * [`calculations/`](aiida_mlip/calculations/): Plugin `Calcjob` classes + * [`singlepoint.py](aiida_mlip/calculations/singlepoint.py ): `Calcjob` class to run single point calculations using mlips + * [`parsers.py`](aiida_mlip/parsers.py): `Parser` for the `Singlepoint` calculation +* [`docs/`](docs/source/): Code documentation + * [`apidoc/`](docs/source/apidoc/): API documentation + * [`developer_guide/`](docs/source/developer_guide/): Documentation for developers + * [`user_guide/`](docs/source/user_guide/): Documentation for users + * [`images/`](docs/source/images/): Logos etc used in the documentation +* [`examples/`](examples/): Examples for submitting calculations using this plugin + * [`calculations/submit_singlepoint.py`](examples/calculations/submit_singlepoint.py): Script for submitting a singlepoint calculation * [`tests/`](tests/): Basic regression tests using the [pytest](https://docs.pytest.org/en/latest/) framework (submitting a calculation, ...). Install `pip install -e .[testing]` and run `pytest`. + * [`conftest.py`](tests/conftest.py): Configuration of fixtures for [pytest](https://docs.pytest.org/en/latest/) + * [`calculations/`](tests/calculations): Calculations + * [`test_singlepoint.py`](tests/calculations/test_singlepoint.py): Test `SinglePoint` calculation + * [`data/`](tests/data): `ModelData` + * [`test_model.py`](tests/data/test_model.py): Test `ModelData` type * [`.gitignore`](.gitignore): Telling git which files to ignore * [`.pre-commit-config.yaml`](.pre-commit-config.yaml): Configuration of [pre-commit hooks](https://pre-commit.com/) that sanitize coding style and check for syntax errors. Enable via `pip install -e .[pre-commit] && pre-commit install` -* [`.readthedocs.yml`](.readthedocs.yml): Configuration of documentation build for [Read the Docs](https://readthedocs.org/) -* [`LICENSE`](LICENSE): License for your plugin +* [`LICENSE`](LICENSE): License for the plugin * [`README.md`](README.md): This file -* [`conftest.py`](conftest.py): Configuration of fixtures for [pytest](https://docs.pytest.org/en/latest/) -* [`pyproject.toml`](setup.json): Python package metadata for registration on [PyPI](https://pypi.org/) and the [AiiDA plugin registry](https://aiidateam.github.io/aiida-registry/) (including entry points) - -See also the following video sequences from the 2019-05 AiiDA tutorial: - - * [run aiida-diff example calculation](https://www.youtube.com/watch?v=2CxiuiA1uVs&t=403s) - * [aiida-diff CalcJob plugin](https://www.youtube.com/watch?v=2CxiuiA1uVs&t=685s) - * [aiida-diff Parser plugin](https://www.youtube.com/watch?v=2CxiuiA1uVs&t=936s) - * [aiida-diff computer/code helpers](https://www.youtube.com/watch?v=2CxiuiA1uVs&t=1238s) - * [aiida-diff input data (with validation)](https://www.youtube.com/watch?v=2CxiuiA1uVs&t=1353s) - * [aiida-diff cli](https://www.youtube.com/watch?v=2CxiuiA1uVs&t=1621s) - * [aiida-diff tests](https://www.youtube.com/watch?v=2CxiuiA1uVs&t=1931s) - * [Adding your plugin to the registry](https://www.youtube.com/watch?v=760O2lDB-TM&t=112s) - * [pre-commit hooks](https://www.youtube.com/watch?v=760O2lDB-TM&t=333s) - -For more information, see the [developer guide](https://aiida-diff.readthedocs.io/en/latest/developer_guide) of your plugin. - - -## Features - - * Add input files using `SinglefileData`: - ```python - SinglefileData = DataFactory('core.singlefile') - inputs['file1'] = SinglefileData(file='/path/to/file1') - inputs['file2'] = SinglefileData(file='/path/to/file2') - ``` - - * Specify command line options via a python dictionary and `DiffParameters`: - ```python - d = { 'ignore-case': True } - DiffParameters = DataFactory('mlip') - inputs['parameters'] = DiffParameters(dict=d) - ``` - - * `DiffParameters` dictionaries are validated using [voluptuous](https://github.com/alecthomas/voluptuous). - Find out about supported options: - ```python - DiffParameters = DataFactory('mlip') - print(DiffParameters.schema.schema) - ``` +* [`tox.ini`](tox.ini): File to set up tox +* [`pyproject.toml`](pyproject.toml): Python package metadata for registration on [PyPI](https://pypi.org/) and the [AiiDA plugin registry](https://aiidateam.github.io/aiida-registry/) (including entry points) + + + +## Features (in development) + +- [x] Supports multiple MLIPs + - MACE + - M3GNET + - CHGNET +- [x] Single point calculations +- [ ] Geometry optimisation +- [ ] Molecular Dynamics: + - NVE + - NVT (Langevin(Eijnden/Ciccotti flavour) and Nosé-Hoover (Melchionna flavour)) + - NPT (Nosé-Hoover (Melchiona flavour)) +- [ ] Training ML potentials (MACE only planned) +- [ ] Fine tunning MLIPs (MACE only planned) + +The code relies heavily on ASE, unless something else is mentioned. + ## Installation ```shell pip install aiida-mlip verdi quicksetup # better to set up a new profile -verdi plugin list aiida.calculations # should now show your calclulation plugins +verdi plugin list aiida.calculations # should now show your calculation plugins ``` @@ -90,16 +82,10 @@ A quick demo of how to submit a calculation: ```shell verdi daemon start # make sure the daemon is running cd examples -./example_01.py # run test calculation +verdi run submit_singlepoint.py "janus@localhost" --calctype "singlepoint" --architecture mace --model "~./cache/mlips/mace/46jrkm3v" # run test calculation verdi process list -a # check record of calculation ``` -The plugin also includes verdi commands to inspect its data types: -```shell -verdi data mlip list -verdi data mlip export -``` - ## Development 1. Install [poetry](https://python-poetry.org/docs/#installation) diff --git a/aiida_mlip/__init__.py b/aiida_mlip/__init__.py index b341aa04..b49efb64 100644 --- a/aiida_mlip/__init__.py +++ b/aiida_mlip/__init__.py @@ -1,7 +1,5 @@ """ -aiida_mlip - -machine learning interatomic potentials aiida plugin +Machine learning interatomic potentials aiida plugin. """ __version__ = "0.1.0a1" diff --git a/aiida_mlip/calculations.py b/aiida_mlip/calculations.py deleted file mode 100644 index d39c3235..00000000 --- a/aiida_mlip/calculations.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -Calculations provided by aiida_mlip. - -Register calculations via the "aiida.calculations" entry point in setup.json. -""" - -from aiida.common import datastructures -from aiida.engine import CalcJob -from aiida.orm import SinglefileData -from aiida.plugins import DataFactory - -DiffParameters = DataFactory("mlip") - - -class DiffCalculation(CalcJob): - """ - AiiDA calculation plugin wrapping the diff executable. - - Simple AiiDA plugin wrapper for 'diffing' two files. - """ - - @classmethod - def define(cls, spec): - """Define inputs and outputs of the calculation.""" - super().define(spec) - - # set default values for AiiDA options - spec.inputs["metadata"]["options"]["resources"].default = { - "num_machines": 1, - "num_mpiprocs_per_machine": 1, - } - spec.inputs["metadata"]["options"]["parser_name"].default = "mlip" - - # new ports - spec.input( - "metadata.options.output_filename", valid_type=str, default="patch.diff" - ) - spec.input( - "parameters", - valid_type=DiffParameters, - help="Command line parameters for diff", - ) - spec.input( - "file1", valid_type=SinglefileData, help="First file to be compared." - ) - spec.input( - "file2", valid_type=SinglefileData, help="Second file to be compared." - ) - spec.output( - "mlip", - valid_type=SinglefileData, - help="diff between file1 and file2.", - ) - - spec.exit_code( - 300, - "ERROR_MISSING_OUTPUT_FILES", - message="Calculation did not produce all expected output files.", - ) - - def prepare_for_submission(self, folder): - """ - Create input files. - - :param folder: an `aiida.common.folders.Folder` where the plugin should temporarily place all files - needed by the calculation. - :return: `aiida.common.datastructures.CalcInfo` instance - """ - codeinfo = datastructures.CodeInfo() - codeinfo.cmdline_params = self.inputs.parameters.cmdline_params( - file1_name=self.inputs.file1.filename, file2_name=self.inputs.file2.filename - ) - codeinfo.code_uuid = self.inputs.code.uuid - codeinfo.stdout_name = self.metadata.options.output_filename - - # Prepare a `CalcInfo` to be returned to the engine - calcinfo = datastructures.CalcInfo() - calcinfo.codes_info = [codeinfo] - calcinfo.local_copy_list = [ - ( - self.inputs.file1.uuid, - self.inputs.file1.filename, - self.inputs.file1.filename, - ), - ( - self.inputs.file2.uuid, - self.inputs.file2.filename, - self.inputs.file2.filename, - ), - ] - calcinfo.retrieve_list = [self.metadata.options.output_filename] - - return calcinfo diff --git a/aiida_mlip/calculations/__init__.py b/aiida_mlip/calculations/__init__.py new file mode 100644 index 00000000..0004b937 --- /dev/null +++ b/aiida_mlip/calculations/__init__.py @@ -0,0 +1,3 @@ +""" +Calculations using MLIPs. +""" diff --git a/aiida_mlip/calculations/singlepoint.py b/aiida_mlip/calculations/singlepoint.py new file mode 100644 index 00000000..007f6417 --- /dev/null +++ b/aiida_mlip/calculations/singlepoint.py @@ -0,0 +1,263 @@ +"""Class to run single point calculations.""" + +from aiida.common import datastructures +import aiida.common.folders +from aiida.engine import CalcJob, CalcJobProcessSpec +import aiida.engine.processes +from aiida.orm import Dict, SinglefileData, Str, StructureData + +from aiida_mlip.data.model import ModelData + + +class Singlepoint(CalcJob): # numpydoc ignore=PR01 + """ + Calcjob implementation to run single point calculations using mlips. + + Attributes + ---------- + _DEFAULT_OUTPUT_FILE : str + Default stdout file name. + _DEFAULT_INPUT_FILE : str + Default input file name. + _XYZ_OUTPUT : str + Default xyz output file name. + _LOG_FILE : str + Default log file name. + + Methods + ------- + define(spec: CalcJobProcessSpec) -> None: + Define the process specification, its inputs, outputs and exit codes. + validate_inputs(value: dict, port_namespace: PortNamespace) -> Optional[str]: + Check if the inputs are valid. + prepare_for_submission(folder: Folder) -> CalcInfo: + Create the input files for the `CalcJob`. + """ + + _DEFAULT_OUTPUT_FILE = "aiida-stdout.txt" + _DEFAULT_INPUT_FILE = "aiida.cif" + _XYZ_OUTPUT = "aiida-results.xyz" + _LOG_FILE = "aiida.log" + + @classmethod + def define(cls, spec: CalcJobProcessSpec) -> None: + """ + Define the process specification, its inputs, outputs and exit codes. + + Parameters + ---------- + spec : `aiida.engine.CalcJobProcessSpec` + The calculation job process spec to define. + """ + super().define(spec) + + # Define inputs + spec.input( + "calctype", + valid_type=Str, + default=lambda: Str("singlepoint"), + help="calculation type (single point or geom opt)", + ) + spec.input( + "architecture", + valid_type=Str, + default=lambda: Str("mace"), + help="Architecture to use for calculation, defaults to mace", + ) + spec.input( + "model", + valid_type=ModelData, + required=False, + help="mlip model used for calculation", + ) + spec.input("structure", valid_type=StructureData, help="The input structure.") + spec.input("precision", valid_type=Str, help="Precision level for calculation") + spec.input( + "device", + valid_type=Str, + required=False, + default=lambda: Str("cpu"), + help="Device on which to run calculation (cpu, cuda or mps)", + ) + + spec.input( + "xyz_output_name", + valid_type=Str, + required=False, + default=lambda: Str(cls._XYZ_OUTPUT), + help="Name of the xyz output file", + ) + + spec.input( + "log_filename", + valid_type=Str, + required=False, + default=lambda: Str(cls._LOG_FILE), + help="Name of the log output file", + ) + spec.input( + "metadata.options.output_filename", + valid_type=str, + default=cls._DEFAULT_OUTPUT_FILE, + ) + spec.input( + "metadata.options.input_filename", + valid_type=str, + default=cls._DEFAULT_INPUT_FILE, + ) + spec.input( + "metadata.options.scheduler_stdout", + valid_type=str, + default="_scheduler-stdout.txt", + help="Filename to which the content of stdout of the scheduler is written.", + ) + spec.inputs["metadata"]["options"]["parser_name"].default = "janus.parser" + spec.inputs.validator = cls.validate_inputs + # Define outputs. The default is a dictionary with the content of the xyz file + spec.output( + "results_dict", + valid_type=Dict, + help="The `results_dict` output node of the successful calculation.", + ) + print("defining outputs") + spec.output("std_output", valid_type=SinglefileData) + spec.output("log_output", valid_type=SinglefileData) + spec.output("xyz_output", valid_type=SinglefileData) + print("defining outputnode") + spec.default_output_node = "results_dict" + + # Exit codes + + spec.exit_code( + 305, + "ERROR_MISSING_OUTPUT_FILES", + message="Some output files missing or cannot be read", + ) + + @classmethod + def validate_inputs( + cls, inputs: dict, port_namespace: aiida.engine.processes.ports.PortNamespace + ): + """ + Check if the inputs are valid. + + Parameters + ---------- + inputs : dict + The inputs dictionary. + + port_namespace : `aiida.engine.processes.ports.PortNamespace` + An instance of aiida's `PortNameSpace`. + + Raises + ------ + ValueError + Error message if validation fails, None otherwise. + """ + # Wrapping processes may choose to exclude certain input ports + # If the ports have been excluded, skip the validation. + if any(key not in port_namespace for key in ("calctype", "structure")): + raise ValueError("Both 'calctype' and 'structure' namespaces are required.") + + for key in ("calctype", "structure"): + if key not in inputs: + raise ValueError( + f"Required value was not provided for the `{key}` namespace." + ) + + valid_calctypes = {"singlepoint", "geom opt"} + if ( + "calctype" in inputs + and str(inputs["calctype"].value) not in valid_calctypes + ): + raise ValueError( + f"The 'calctype' must be one of {valid_calctypes}, " + f"but got '{inputs['calctype']}'." + ) + + if "input_filename" in inputs: + if not inputs["input_filename"].value.endswith(".cif"): + raise ValueError("The parameter 'input_filename' must end with '.cif'") + + # pylint: disable=too-many-locals + def prepare_for_submission( + self, folder: aiida.common.folders.Folder + ) -> datastructures.CalcInfo: + """ + Create the input files for the `Calcjob`. + + Parameters + ---------- + folder : aiida.common.folders.Folder + An `aiida.common.folders.Folder` to temporarily write files on disk. + + Returns + ------- + aiida.common.datastructures.CalcInfo + An instance of `aiida.common.datastructures.CalcInfo`. + """ + # Create needed inputs + # Define architecture from model if model is given, + # otherwise get architecture from inputs and download default model + architecture = ( + str((self.inputs.model).architecture) + if self.inputs.model + else str(self.inputs.architecture.value) + ) + if self.inputs.model: + model_path = self.inputs.model.filepath + else: + model_path = ModelData.download( + "https://github.com/stfc/janus-core/raw/main/tests/models/mace_mp_small.model", # pylint:disable=line-too-long + architecture, + ).filepath + + # The inputs are saved in the node, but we want their value as a string + calctype = (self.inputs.calctype).value + precision = (self.inputs.precision).value + device = (self.inputs.device).value + xyz_filename = (self.inputs.xyz_output_name).value + input_filename = self.inputs.metadata.options.input_filename + log_filename = (self.inputs.log_filename).value + # Transform the structure data in cif file called input_filename + structure = self.inputs.structure + cif_structure = structure.get_cif() + with folder.open(self._DEFAULT_INPUT_FILE, "w", encoding="utf-8") as inputfile: + inputfile.write(cif_structure.get_content()) + + cmd_line = { + "arch": architecture, + "struct": input_filename, + "device": device, + "log": log_filename, + "calc-kwargs": {"model": model_path, "default_dtype": precision}, + "write-kwargs": {"filename": xyz_filename}, + } + + codeinfo = datastructures.CodeInfo() + + # Initialize cmdline_params as an empty list + codeinfo.cmdline_params = [] + # Adding command line params for when we run janus + codeinfo.cmdline_params.append(calctype) + for flag, value in cmd_line.items(): + codeinfo.cmdline_params += [f"--{flag}", str(value)] + + # Node where the code is saved + codeinfo.code_uuid = self.inputs.code.uuid + # Save name of output as you need it for running the code + codeinfo.stdout_name = self.metadata.options.output_filename + + calcinfo = datastructures.CalcInfo() + calcinfo.codes_info = [codeinfo] + # Save the info about the node where the calc is stored + calcinfo.uuid = str(self.uuid) + # Retrieve output files + calcinfo.retrieve_list = [ + self.metadata.options.output_filename, + xyz_filename, + self.uuid, + log_filename, + ] + + return calcinfo diff --git a/aiida_mlip/cli.py b/aiida_mlip/cli.py deleted file mode 100644 index e6fd19ea..00000000 --- a/aiida_mlip/cli.py +++ /dev/null @@ -1,62 +0,0 @@ -""" -Command line interface (cli) for aiida_mlip. - -Register new commands either via the "console_scripts" entry point or plug them -directly into the 'verdi' command by using AiiDA-specific entry points like -"aiida.cmdline.data" (both in the setup.json file). -""" - -import sys - -import click - -from aiida.cmdline.commands.cmd_data import verdi_data -from aiida.cmdline.params.types import DataParamType -from aiida.cmdline.utils import decorators -from aiida.orm import QueryBuilder -from aiida.plugins import DataFactory - - -# See aiida.cmdline.data entry point in setup.json -@verdi_data.group("mlip") -def data_cli(): - """Command line interface for aiida-mlip""" - - -@data_cli.command("list") -@decorators.with_dbenv() -def list_(): # pylint: disable=redefined-builtin - """ - Display all DiffParameters nodes - """ - DiffParameters = DataFactory("mlip") - - qb = QueryBuilder() - qb.append(DiffParameters) - results = qb.all() - - s = "" - for result in results: - obj = result[0] - s += f"{str(obj)}, pk: {obj.pk}\n" - sys.stdout.write(s) - - -@data_cli.command("export") -@click.argument("node", metavar="IDENTIFIER", type=DataParamType()) -@click.option( - "--outfile", - "-o", - type=click.Path(dir_okay=False), - help="Write output to file (default: print to stdout).", -) -@decorators.with_dbenv() -def export(node, outfile): - """Export a DiffParameters node (identified by PK, UUID or label) to plain text.""" - string = str(node) - - if outfile: - with open(outfile, "w", encoding="utf8") as f: - f.write(string) - else: - click.echo(string) diff --git a/aiida_mlip/data/__init__.py b/aiida_mlip/data/__init__.py index 9502a4d3..30b7a37a 100644 --- a/aiida_mlip/data/__init__.py +++ b/aiida_mlip/data/__init__.py @@ -1,94 +1,3 @@ """ -Data types provided by plugin - -Register data types via the "aiida.data" entry point in setup.json. +Data types for MLIPs calculations. """ - -# You can directly use or subclass aiida.orm.data.Data -# or any other data type listed under 'verdi data' -from voluptuous import Optional, Schema - -from aiida.orm import Dict - -# A subset of diff's command line options -cmdline_options = { - Optional("ignore-case"): bool, - Optional("ignore-file-name-case"): bool, - Optional("ignore-tab-expansion"): bool, - Optional("ignore-space-change"): bool, - Optional("ignore-all-space"): bool, -} - - -class DiffParameters(Dict): # pylint: disable=too-many-ancestors - """ - Command line options for diff. - - This class represents a python dictionary used to - pass command line options to the executable. - """ - - # "voluptuous" schema to add automatic validation - schema = Schema(cmdline_options) - - # pylint: disable=redefined-builtin - def __init__(self, dict=None, **kwargs): - """ - Constructor for the data class - - Usage: ``DiffParameters(dict{'ignore-case': True})`` - - :param parameters_dict: dictionary with commandline parameters - :param type parameters_dict: dict - - """ - dict = self.validate(dict) - super().__init__(dict=dict, **kwargs) - - def validate(self, parameters_dict): - """Validate command line options. - - Uses the voluptuous package for validation. Find out about allowed keys using:: - - print(DiffParameters).schema.schema - - :param parameters_dict: dictionary with commandline parameters - :param type parameters_dict: dict - :returns: validated dictionary - """ - return DiffParameters.schema(parameters_dict) - - def cmdline_params(self, file1_name, file2_name): - """Synthesize command line parameters. - - e.g. [ '--ignore-case', 'filename1', 'filename2'] - - :param file_name1: Name of first file - :param type file_name1: str - :param file_name2: Name of second file - :param type file_name2: str - - """ - parameters = [] - - pm_dict = self.get_dict() - for option, enabled in pm_dict.items(): - if enabled: - parameters += ["--" + option] - - parameters += [file1_name, file2_name] - - return [str(p) for p in parameters] - - def __str__(self): - """String representation of node. - - Append values of dictionary to usual representation. E.g.:: - - uuid: b416cbee-24e8-47a8-8c11-6d668770158b (pk: 590) - {'ignore-case': True} - - """ - string = super().__str__() - string += "\n" + str(self.get_dict()) - return string diff --git a/aiida_mlip/data/model.py b/aiida_mlip/data/model.py index 80ce40bb..8fa8fb95 100644 --- a/aiida_mlip/data/model.py +++ b/aiida_mlip/data/model.py @@ -10,14 +10,44 @@ class ModelData(SinglefileData): - """Class to save a model file as an AiiDA data type. - - The file can be a file that is stored locally or a new file to download. + """ + Define Model Data type in AiiDA. + + Parameters + ---------- + file : Union[str, Path] + Absolute path to the file. + architecture : str + Architecture of the mlip model. + filename : Optional[str], optional + Name to be used for the file (defaults to the name of provided file). + + Attributes + ---------- + architecture : str + Architecture of the mlip model. + filepath : str + Path of the mlip model. + + Methods + ------- + set_file(file, filename=None, architecture=None, **kwargs) + Set the file for the node. + local_file(file, architecture, filename=None): + Create a ModelData instance from a local file. + download(url, architecture, filename=None, cache_dir=None, force_download=False) + Download a file from a URL and save it as ModelData. + + Other Parameters + ---------------- + **kwargs : Any + Additional keyword arguments. """ @staticmethod def _calculate_hash(file: Union[str, Path]) -> str: - """Calculate the hash of a file. + """ + Calculate the hash of a file. Parameters ---------- @@ -41,7 +71,8 @@ def _calculate_hash(file: Union[str, Path]) -> str: @classmethod def _check_existing_file(cls, file: Union[str, Path]) -> Path: - """Check if a file already exists and return the path of the existing file if it does. + """ + Check if a file already exists and return the path of the existing file. Parameters ---------- @@ -51,11 +82,25 @@ def _check_existing_file(cls, file: Union[str, Path]) -> Path: Returns ------- Path - The path of the model file of interest (same as input path if no duplicates were found). + The path of the model file of interest (same as input path if no duplicates + were found). """ file_hash = cls._calculate_hash(file) - def is_diff_file(curr_path: Path): + def is_diff_file(curr_path: Path) -> bool: + """ + Filter to check if two files are different. + + Parameters + ---------- + curr_path : Path + Path to the file to compare with. + + Returns + ------- + bool + True if the files are different, False otherwise. + """ return curr_path.is_file() and not curr_path.samefile(file) file_folder = Path(file).parent @@ -72,7 +117,8 @@ def __init__( filename: Optional[str] = None, **kwargs: Any, ) -> None: - """Initialize the ModelData object. + """ + Initialize the ModelData object. Parameters ---------- @@ -85,11 +131,12 @@ def __init__( Other Parameters ---------------- - kwargs : Any + **kwargs : Any Additional keyword arguments. """ super().__init__(file, filename, **kwargs) self.base.attributes.set("architecture", architecture) + self.base.attributes.set("filepath", str(file)) def set_file( self, @@ -98,25 +145,26 @@ def set_file( architecture: Optional[str] = None, **kwargs: Any, ) -> None: - """Set the file for the node. + """ + Set the file for the node. Parameters ---------- file : Union[str, Path] Absolute path to the file. - architecture : [str] - Architecture of the mlip model. filename : Optional[str], optional Name to be used for the file (defaults to the name of provided file). - + architecture : Optional[str], optional + Architecture of the mlip model. Other Parameters ---------------- - kwargs : Any + **kwargs : Any Additional keyword arguments. """ super().set_file(file, filename, **kwargs) self.base.attributes.set("architecture", architecture) + self.base.attributes.set("filepath", str(file)) @classmethod def local_file( @@ -125,7 +173,8 @@ def local_file( architecture: str, filename: Optional[str] = None, ): - """Create a ModelData instance from a local file. + """ + Create a ModelData instance from a local file. Parameters ---------- @@ -154,7 +203,8 @@ def download( cache_dir: Optional[Union[str, Path]] = None, force_download: Optional[bool] = False, ): - """Download a file from a URL and save it as ModelData. + """ + Download a file from a URL and save it as ModelData. Parameters ---------- @@ -165,16 +215,20 @@ def download( filename : Optional[str], optional Name to be used for the file (defaults to the name of provided file). cache_dir : Optional[Union[str, Path]], optional - Path to the folder where the file has to be saved (defaults to "~/.cache/mlips/"). + Path to the folder where the file has to be saved + (defaults to "~/.cache/mlips/"). force_download : Optional[bool], optional - True to keep the downloaded model even if there are duplicates (default: False). + True to keep the downloaded model even if there are duplicates + (default: False). Returns ------- ModelData A ModelData instance. """ - cache_dir = Path(cache_dir if cache_dir else "~/.cache/mlips/") + cache_dir = ( + Path(cache_dir) if cache_dir else Path("~/.cache/mlips/").expanduser() + ) arch_dir = (cache_dir / architecture) if architecture else cache_dir # cache_path = cache_dir.resolve() @@ -199,14 +253,15 @@ def download( print(f"filename changed to {file}") return cls.local_file(file=file, architecture=architecture) - # Check if the hash of the just downloaded file matches any other file in the directory + # Check if the hash of the just downloaded file matches any other file filepath = cls._check_existing_file(file) return cls.local_file(file=filepath, architecture=architecture) @property def architecture(self) -> str: - """Return the architecture. + """ + Return the architecture. Returns ------- @@ -214,3 +269,15 @@ def architecture(self) -> str: Architecture of the mlip model. """ return self.base.attributes.get("architecture") + + @property + def filepath(self) -> str: + """ + Return the filepath. + + Returns + ------- + str + Path of the mlip model. + """ + return self.base.attributes.get("filepath") diff --git a/aiida_mlip/helpers.py b/aiida_mlip/helpers.py deleted file mode 100644 index 47465344..00000000 --- a/aiida_mlip/helpers.py +++ /dev/null @@ -1,99 +0,0 @@ -""" Helper functions for automatically setting up computer & code. -Helper functions for setting up - - 1. An AiiDA localhost computer - 2. A "diff" code on localhost - -Note: Point 2 is made possible by the fact that the ``diff`` executable is -available in the PATH on almost any UNIX system. -""" - -import shutil -import tempfile - -from aiida.common.exceptions import NotExistent -from aiida.orm import Code, Computer - -LOCALHOST_NAME = "localhost-test" - -executables = { - "mlip": "diff", -} - - -def get_path_to_executable(executable): - """Get path to local executable. - :param executable: Name of executable in the $PATH variable - :type executable: str - :return: path to executable - :rtype: str - """ - path = shutil.which(executable) - if path is None: - raise ValueError(f"'{executable}' executable not found in PATH.") - return path - - -def get_computer(name=LOCALHOST_NAME, workdir=None): - """Get AiiDA computer. - Loads computer 'name' from the database, if exists. - Sets up local computer 'name', if it isn't found in the DB. - - :param name: Name of computer to load or set up. - :param workdir: path to work directory - Used only when creating a new computer. - :return: The computer node - :rtype: :py:class:`aiida.orm.computers.Computer` - """ - - try: - computer = Computer.objects.get(label=name) - except NotExistent: - if workdir is None: - workdir = tempfile.mkdtemp() - - computer = Computer( - label=name, - description="localhost computer set up by aiida_diff tests", - hostname=name, - workdir=workdir, - transport_type="core.local", - scheduler_type="core.direct", - ) - computer.store() - computer.set_minimum_job_poll_interval(0.0) - computer.configure() - - return computer - - -def get_code(entry_point, computer): - """Get local code. - Sets up code for given entry point on given computer. - - :param entry_point: Entry point of calculation plugin - :param computer: (local) AiiDA computer - :return: The code node - :rtype: :py:class:`aiida.orm.nodes.data.code.installed.InstalledCode` - """ - - try: - executable = executables[entry_point] - except KeyError as exc: - raise KeyError( - f"Entry point '{entry_point}' not recognized. Allowed values: {list(executables.keys())}" - ) from exc - - codes = Code.objects.find( # pylint: disable=no-member - filters={"label": executable} - ) - if codes: - return codes[0] - - path = get_path_to_executable(executable) - code = Code( - input_plugin_name=entry_point, - remote_computer_exec=[computer, path], - ) - code.label = executable - return code.store() diff --git a/aiida_mlip/parsers.py b/aiida_mlip/parsers.py index e20128c2..adfcea9c 100644 --- a/aiida_mlip/parsers.py +++ b/aiida_mlip/parsers.py @@ -1,47 +1,106 @@ """ Parsers provided by aiida_mlip. - -Register parsers via the "aiida.parsers" entry point in setup.json. """ +from pathlib import Path + +from ase.io import read +import numpy as np + from aiida.common import exceptions from aiida.engine import ExitCode -from aiida.orm import SinglefileData +from aiida.orm import Dict, SinglefileData +from aiida.orm.nodes.process.process import ProcessNode from aiida.parsers.parser import Parser from aiida.plugins import CalculationFactory -DiffCalculation = CalculationFactory("mlip") +singlePointCalculation = CalculationFactory("janus.sp") + + +def convert_numpy(dictionary: dict) -> dict: + """ + A function to convert numpy ndarrays in dictionary into lists. + Parameters + ---------- + dictionary : dict + A dictionary with numpy array values to be converted into lists. -class DiffParser(Parser): + Returns + ------- + dict + Converted dictionary. + """ + for key, value in dictionary.items(): + if isinstance(value, np.ndarray): + dictionary[key] = value.tolist() + return dictionary + + +class SPParser(Parser): """ Parser class for parsing output of calculation. + + Parameters + ---------- + node : aiida.orm.nodes.process.process.ProcessNode + ProcessNode of calculation. + + Methods + ------- + __init__(node: aiida.orm.nodes.process.process.ProcessNode) + Initialize the SPParser instance. + + parse(**kwargs: Any) -> int: + Parse outputs, store results in the database. + + Returns + ------- + int + An exit code. + + Raises + ------ + exceptions.ParsingError + If the ProcessNode being passed was not produced by a singlePointCalculation. """ - def __init__(self, node): + def __init__(self, node: ProcessNode): """ - Initialize Parser instance - - Checks that the ProcessNode being passed was produced by a DiffCalculation. + Check that the ProcessNode being passed was produced by a `Singlepoint`. - :param node: ProcessNode of calculation - :param type node: :class:`aiida.orm.nodes.process.process.ProcessNode` + Parameters + ---------- + node : aiida.orm.nodes.process.process.ProcessNode + ProcessNode of calculation. """ super().__init__(node) - if not issubclass(node.process_class, DiffCalculation): - raise exceptions.ParsingError("Can only parse DiffCalculation") - def parse(self, **kwargs): + if not issubclass(node.process_class, singlePointCalculation): + raise exceptions.ParsingError("Can only parse `Singlepoint` calculations") + + def parse(self, **kwargs) -> int: """ - Parse outputs, store results in database. + Parse outputs, store results in the database. + + Parameters + ---------- + **kwargs : Any + Any keyword arguments. - :returns: an exit code, if parsing fails (or nothing if parsing succeeds) + Returns + ------- + int + An exit code. """ output_filename = self.node.get_option("output_filename") + xyzoutput = (self.node.inputs.xyz_output_name).value + logoutput = (self.node.inputs.log_filename).value # Check that folder content is as expected files_retrieved = self.retrieved.list_object_names() - files_expected = [output_filename] + + files_expected = {xyzoutput, logoutput} # Note: set(A) <= set(B) checks whether A is a subset of B if not set(files_expected) <= set(files_retrieved): self.logger.error( @@ -49,10 +108,19 @@ def parse(self, **kwargs): ) return self.exit_codes.ERROR_MISSING_OUTPUT_FILES - # add output file - self.logger.info(f"Parsing '{output_filename}'") + # Add output file to the outputs + self.logger.info(f"Parsing '{xyzoutput}'") + + with self.retrieved.open(logoutput, "rb") as handle: + self.out("log_output", SinglefileData(file=handle)) + with self.retrieved.open(xyzoutput, "rb") as handle: + self.out("xyz_output", SinglefileData(file=handle)) with self.retrieved.open(output_filename, "rb") as handle: - output_node = SinglefileData(file=handle) - self.out("mlip", output_node) + self.out("std_output", SinglefileData(file=handle)) + + content = read(Path(self.node.get_remote_workdir(), xyzoutput)) + results = convert_numpy(content.todict()) + results_node = Dict(results) + self.out("results_dict", results_node) return ExitCode(0) diff --git a/conftest.py b/conftest.py deleted file mode 100644 index e587cd2d..00000000 --- a/conftest.py +++ /dev/null @@ -1,16 +0,0 @@ -"""pytest fixtures for simplified testing.""" - -import pytest - -pytest_plugins = ["aiida.manage.tests.pytest_fixtures"] - - -@pytest.fixture(scope="function", autouse=True) -def clear_database_auto(clear_database): # pylint: disable=unused-argument - """Automatically clear database in between tests.""" - - -@pytest.fixture(scope="function") -def mlip_code(aiida_local_code_factory): - """Get a mlip code.""" - return aiida_local_code_factory(executable="diff", entry_point="mlip") diff --git a/docs/source/apidoc/aiida_mlip.calculations.rst b/docs/source/apidoc/aiida_mlip.calculations.rst new file mode 100644 index 00000000..a7998e2a --- /dev/null +++ b/docs/source/apidoc/aiida_mlip.calculations.rst @@ -0,0 +1,25 @@ +aiida\_mlip.calculations package +================================ + +Submodules +---------- + +aiida\_mlip.calculations.singlepoint module +------------------------------------------- + +.. automodule:: aiida_mlip.calculations.singlepoint + :members: + :special-members: + :private-members: + :undoc-members: + :show-inheritance: + +Module contents +--------------- + +.. automodule:: aiida_mlip.calculations + :members: + :special-members: + :private-members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/apidoc/aiida_mlip.rst b/docs/source/apidoc/aiida_mlip.rst index 9b6e1f03..c792a06d 100644 --- a/docs/source/apidoc/aiida_mlip.rst +++ b/docs/source/apidoc/aiida_mlip.rst @@ -7,41 +7,12 @@ Subpackages .. toctree:: :maxdepth: 4 + aiida_mlip.calculations aiida_mlip.data Submodules ---------- -aiida\_mlip.calculations module -------------------------------- - -.. automodule:: aiida_mlip.calculations - :members: - :special-members: - :private-members: - :undoc-members: - :show-inheritance: - -aiida\_mlip.cli module ----------------------- - -.. automodule:: aiida_mlip.cli - :members: - :special-members: - :private-members: - :undoc-members: - :show-inheritance: - -aiida\_mlip.helpers module --------------------------- - -.. automodule:: aiida_mlip.helpers - :members: - :special-members: - :private-members: - :undoc-members: - :show-inheritance: - aiida\_mlip.parsers module -------------------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 5b43ee03..07ff0cf0 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -44,12 +44,16 @@ "sphinxcontrib.contentui", ] +# Add the numpydoc_show_inherited_class_members option +numpydoc_show_inherited_class_members = False + numpydoc_validation_checks = {"all", "EX01", "SA01", "ES01"} numpydoc_validation_exclude = {r"\.__weakref__$", r"\.__repr__$"} intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "aiida": ("https://aiida.readthedocs.io/projects/aiida-core/en/latest", None), + "ase": ("https://wiki.fysik.dtu.dk/ase/", None), } # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/user_guide/calculations.rst b/docs/source/user_guide/calculations.rst new file mode 100644 index 00000000..2bf4816b --- /dev/null +++ b/docs/source/user_guide/calculations.rst @@ -0,0 +1,35 @@ + +============================== +Calculations +============================== + +SinglePoint calculation +----------------------- + +A Single Point Calculation represents a `Calcjob` object within the AiiDA framework. + + +Usage +^^^^^ + +This calculation can be executed using either the `run` or `submit` AiiDA commands. +Below is a usage example with the minimum required parameters. These parameters must be AiiDA data types. + + +.. code-block:: python + + submit(SinglePointCalculation, code=AbstractCode, calctype=Str, model=ModelData, structure=StructureData) + + +Refer to the API documentation for additional parameters that can be passed. + +Submission +^^^^^^^^^^ + +To facilitate the submission process and prepare inputs as AiiDA data types, an example script is provided. +This script can be used as is, submitted to verdi, and the parameters passed as strings to the CLI. +They will be converted to AiiDA data types by the script itself. + +.. code-block:: python + + verdi run submit_singlepoint.py janus@localhost --calctype "singlepoint" --structure "path/to/structure" --model "path/to/model" --precision "float64" --device "cpu" diff --git a/docs/source/user_guide/api.rst b/docs/source/user_guide/data.rst similarity index 100% rename from docs/source/user_guide/api.rst rename to docs/source/user_guide/data.rst diff --git a/docs/source/user_guide/get_started.rst b/docs/source/user_guide/get_started.rst index 15ba0acf..4ae7d7fd 100644 --- a/docs/source/user_guide/get_started.rst +++ b/docs/source/user_guide/get_started.rst @@ -38,5 +38,5 @@ If you have already set up your own aiida_mlip code using Available calculations ++++++++++++++++++++++ -.. aiida-calcjob:: DiffCalculation - :module: aiida_mlip.calculations +.. aiida-calcjob:: Singlepoint + :module: aiida_mlip.calculations.singlepoint diff --git a/docs/source/user_guide/index.rst b/docs/source/user_guide/index.rst index a427468f..fa3680f9 100644 --- a/docs/source/user_guide/index.rst +++ b/docs/source/user_guide/index.rst @@ -7,4 +7,5 @@ User guide get_started tutorial - api + data + calculations diff --git a/examples/calculations/submit_singlepoint.py b/examples/calculations/submit_singlepoint.py new file mode 100644 index 00000000..48a4c86d --- /dev/null +++ b/examples/calculations/submit_singlepoint.py @@ -0,0 +1,170 @@ +"""Example code for submitting single point calculation""" + +from pathlib import Path +from typing import Union + +from ase.build import bulk +from ase.io import read +import click + +from aiida.common import NotExistent +from aiida.engine import run_get_node +from aiida.orm import Str, StructureData, load_code, load_node +from aiida.plugins import CalculationFactory + +from aiida_mlip.data.model import ModelData + + +def load_model(string: Union[str, Path, None], architecture: str) -> ModelData: + """ + Load a model from a given string. + + If the string represents a file path, the model will be loaded from that path. + Otherwise, the model will be downloaded from the specified location. + + Parameters + ---------- + string : Union[str, Path, None] + The string representing either a file path or a URL for downloading the model. + architecture : str + The architecture of the model. + + Returns + ------- + ModelData or None + The loaded model if successful, otherwise None. + """ + if string is None: + model = None + elif (file_path := Path(string)).is_file(): + model = ModelData.local_file(file_path, architecture=architecture) + else: + model = ModelData.download(string, architecture=architecture) + return model + + +def load_structure(struct: Union[str, Path, int, None]) -> StructureData: + """ + Load a StructureData instance from the given input. + + The input can be either a path to a structure file, a node PK (int), + or None. If the input is None, a default StructureData instance for NaCl + with a rocksalt structure will be created. + + Parameters + ---------- + struct : Union[str, Path, int, None] + The input value representing either a path to a structure file, a node PK, + or None. + + Returns + ------- + StructureData + The loaded or created StructureData instance. + + Raises + ------ + click.BadParameter + If the input is not a valid path to a structure file or a node PK. + """ + if struct is None: + structure = StructureData(ase=bulk("NaCl", "rocksalt", 5.63)) + elif isinstance(struct, int) or (isinstance(struct, str) and struct.isdigit()): + structure_pk = int(struct) + structure = load_node(structure_pk) + elif Path.exists(struct): + structure = StructureData(ase=read(struct)) + else: + raise click.BadParameter( + f"Invalid input: {struct}. Must be either node PK (int) or a valid \ + path to a structure file." + ) + return structure + + +def singlepoint(params: dict) -> None: + """ + Prepare inputs and run a single point calculation. + + Parameters + ---------- + params : dict + A dictionary containing the input parameters for the calculations + + Returns + ------- + None + """ + for key, value in params.items(): + print(key, type(value)) + + structure = load_structure(params["file"]) + + # Select model to use + model = load_model(params["model"], params["architecture"]) + + # Select calculation to use + singlePointCalculation = CalculationFactory("janus.sp") + + # Define inputs + inputs = { + "metadata": {"options": {"resources": {"num_machines": 1}}}, + "code": params["code"], + "architecture": Str(params["architecture"]), + "structure": structure, + "calctype": Str(params["calctype"]), + "model": model, + "precision": Str(params["precision"]), + "device": Str(params["device"]), + } + + # Run calculation + result, node = run_get_node(singlePointCalculation, **inputs) + print(f"Printing results from calculation: {result}") + print(f"Printing node of calculation: {node}") + + +# Arguments and options to give to the cli when running the script +@click.command("cli") +@click.argument("codelabel", type=str) +@click.option("--calctype", default="singlepoint", type=str) +@click.option( + "--file", + default=None, + type=str, + help="Specify the structure (aiida node or path to a structure file)", +) +@click.option( + "--model", + default=None, + type=Path, + help="Specify path or url of the model to use", +) +@click.option("--architecture", default="mace_mp", type=str) +@click.option("--device", default="cpu", type=str) +@click.option("--precision", default="float64", type=str) +def cli(codelabel, calctype, file, model, architecture, device, precision) -> None: + # pylint: disable=too-many-arguments + """Click interface.""" + try: + code = load_code(codelabel) + except NotExistent as exc: + print(f"The code '{codelabel}' does not exist.") + raise SystemExit from exc + + params = { + "code": code, + "calctype": calctype, + "file": file, + "model": model, + "architecture": architecture, + "device": device, + "precision": precision, + } + + # Submit single point + singlepoint(params) + + +if __name__ == "__main__": + cli() # pylint: disable=no-value-for-parameter diff --git a/examples/example_01.py b/examples/example_01.py deleted file mode 100644 index 6c3d44b5..00000000 --- a/examples/example_01.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python -"""Run a test calculation on localhost. - -Usage: ./example_01.py -""" -from os import path - -import click - -from aiida import cmdline, engine -from aiida.plugins import CalculationFactory, DataFactory - -from aiida_mlip import helpers - -INPUT_DIR = path.join(path.dirname(path.realpath(__file__)), "input_files") - - -def test_run(mlip_code): - """Run a calculation on the localhost computer. - - Uses test helpers to create AiiDA Code on the fly. - """ - if not mlip_code: - # get code - computer = helpers.get_computer() - mlip_code = helpers.get_code(entry_point="mlip", computer=computer) - - # Prepare input parameters - DiffParameters = DataFactory("mlip") - parameters = DiffParameters({"ignore-case": True}) - - SinglefileData = DataFactory("core.singlefile") - file1 = SinglefileData(file=path.join(INPUT_DIR, "file1.txt")) - file2 = SinglefileData(file=path.join(INPUT_DIR, "file2.txt")) - - # set up calculation - inputs = { - "code": mlip_code, - "parameters": parameters, - "file1": file1, - "file2": file2, - "metadata": { - "description": "Test job submission with the aiida_mlip plugin", - }, - } - - # Note: in order to submit your calculation to the aiida daemon, do: - # from aiida.engine import submit - # future = submit(CalculationFactory('mlip'), **inputs) - result = engine.run(CalculationFactory("mlip"), **inputs) - - computed_diff = result["mlip"].get_content() - print(f"Computed diff between files: \n{computed_diff}") - - -@click.command() -@cmdline.utils.decorators.with_dbenv() -@cmdline.params.options.CODE() -def cli(code): - """Run example. - - Example usage: $ ./example_01.py --code diff@localhost - - Alternative (creates diff@localhost-test code): $ ./example_01.py - - Help: $ ./example_01.py --help - """ - test_run(code) - - -if __name__ == "__main__": - cli() # pylint: disable=no-value-for-parameter diff --git a/examples/input_files/__init__.py b/examples/input_files/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/examples/input_files/file1.txt b/examples/input_files/file1.txt deleted file mode 100644 index 3f2cce17..00000000 --- a/examples/input_files/file1.txt +++ /dev/null @@ -1,2 +0,0 @@ -file with content -content1 diff --git a/examples/input_files/file2.txt b/examples/input_files/file2.txt deleted file mode 100644 index 5bb0129d..00000000 --- a/examples/input_files/file2.txt +++ /dev/null @@ -1,2 +0,0 @@ -file with content -content2 diff --git a/pyproject.toml b/pyproject.toml index ad48dd56..2f18c332 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,10 +28,13 @@ documentation = "https://stfc.github.io/aiida-mlip/" [tool.poetry.dependencies] python = "^3.9" aiida-core = "^2.5" +ase = "^3.22.1" voluptuous = "^0.14" +PyCifRW = "^4.4.6" [tool.poetry.group.dev.dependencies] coverage = {extras = ["toml"], version = "^7.4.1"} +janus-core = "^0.1.0b2" pgtest = "^1.3.2" pytest = "^8.0" pytest-cov = "^4.1.0" @@ -62,16 +65,14 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.plugins] # Optional super table [tool.poetry.plugins."aiida.data"] -"mlip" = "aiida_mlip.data:DiffParameters" +"janus.modeldata" = "aiida_mlip.data.model:ModelData" [tool.poetry.plugins."aiida.calculations"] -"mlip" = "aiida_mlip.calculations:DiffCalculation" +"janus.sp" = "aiida_mlip.calculations.singlepoint:Singlepoint" [tool.poetry.plugins."aiida.parsers"] -"mlip" = "aiida_mlip.parsers:DiffParser" +"janus.parser" = "aiida_mlip.parsers:SPParser" -[tool.poetry.plugins."aiida.cmdline.data"] -"mlip" = "aiida_mlip.cli:data_cli" [tool.black] line-length = 88 diff --git a/tests/calculations/test_singlepoint.py b/tests/calculations/test_singlepoint.py new file mode 100644 index 00000000..82ba941a --- /dev/null +++ b/tests/calculations/test_singlepoint.py @@ -0,0 +1,136 @@ +"""Tests for singlepoint calculation.""" + +import subprocess + +from ase.build import bulk +import pytest + +from aiida.common import datastructures +from aiida.engine import run +from aiida.orm import Str, StructureData +from aiida.plugins import CalculationFactory + +from aiida_mlip.data.model import ModelData + + +def test_singlepoint(fixture_sandbox, generate_calc_job, tmp_path, janus_code): + """Test generating singlepoint calculation job""" + # pylint:disable=line-too-long + entry_point_name = "janus.sp" + inputs = { + "metadata": {"options": {"resources": {"num_machines": 1}}}, + "code": janus_code, + "architecture": Str("mace"), + "precision": Str("float64"), + "structure": StructureData(ase=bulk("NaCl", "rocksalt", 5.63)), + "calctype": Str("singlepoint"), + "model": ModelData.download( + "https://github.com/stfc/janus-core/raw/main/tests/models/mace_mp_small.model", + architecture="mace", + cache_dir=tmp_path, + ), + "device": Str("cpu"), + } + + calc_info = generate_calc_job(fixture_sandbox, entry_point_name, inputs) + # pylint:disable=line-too-long + cmdline_params = [ + "singlepoint", + "--arch", + "mace", + "--struct", + "aiida.cif", + "--device", + "cpu", + "--log", + "aiida.log", + "--calc-kwargs", + f"{{'model': '{tmp_path}/mace/mace_mp_small.model', 'default_dtype': 'float64'}}", + "--write-kwargs", + "{'filename': 'aiida-results.xyz'}", + ] + + retrieve_list = [ + calc_info.uuid, + "aiida.log", + "aiida-results.xyz", + "aiida-stdout.txt", + ] + + # Check the attributes of the returned `CalcInfo` + assert sorted(fixture_sandbox.get_content_list()) == ["aiida.cif"] + assert isinstance(calc_info, datastructures.CalcInfo) + assert isinstance(calc_info.codes_info[0], datastructures.CodeInfo) + assert sorted(calc_info.codes_info[0].cmdline_params) == sorted(cmdline_params) + assert sorted(calc_info.retrieve_list) == sorted(retrieve_list) + + +def test_sp_error(fixture_sandbox, generate_calc_job, tmp_path, fixture_code): + """Test singlepoint calculation with error input""" + entry_point_name = "janus.sp" + # pylint:disable=line-too-long + inputs = { + "metadata": {"options": {"resources": {"num_machines": 1}}}, + "code": fixture_code, + "architecture": Str("mace"), + "precision": Str("float64"), + "structure": StructureData(ase=bulk("NaCl", "rocksalt", 5.63)), + "calctype": Str("wrong_type"), + "model": ModelData.download( + "https://github.com/stfc/janus-core/raw/main/tests/models/mace_mp_small.model", + architecture="mace", + cache_dir=tmp_path, + ), + "device": Str("cpu"), + } + with pytest.raises(ValueError): + generate_calc_job(fixture_sandbox, entry_point_name, inputs) + + +def test_run_sp(tmp_path, janus_code): + """Test running singlepoint calculation""" + # pylint:disable=line-too-long + inputs = { + "metadata": {"options": {"resources": {"num_machines": 1}}}, + "code": janus_code, + "architecture": Str("mace"), + "precision": Str("float64"), + "structure": StructureData(ase=bulk("NaCl", "rocksalt", 5.63)), + "calctype": Str("singlepoint"), + "model": ModelData.download( + "https://github.com/stfc/janus-core/raw/main/tests/models/mace_mp_small.model", + architecture="mace", + cache_dir=tmp_path, + ), + "device": Str("cpu"), + } + + singlePointCalculation = CalculationFactory("janus.sp") + result = run(singlePointCalculation, **inputs) + + assert "results_dict" in result + obtained_res = result["results_dict"].get_dict() + assert "log_output" in result + assert "xyz_output" in result + assert "std_output" in result + assert obtained_res["info"]["energy"] == pytest.approx(-6.7575203839729) + assert obtained_res["info"]["stress"][0][0] == pytest.approx(-0.005816546985101) + + +def test_example(example_file_path): + """ + Test function to execute the example file with specific command arguments. + """ + command = [ + "verdi", + "run", + example_file_path, + "janus@localhost", + "--calctype", + "singlepoint", + ] + + # Execute the command + result = subprocess.run(command, capture_output=True, text=True, check=False) + assert result.stderr == "" + assert result.returncode == 0 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..1f324544 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,211 @@ +# pylint: disable=redefined-outer-name,too-many-statements +"""Initialise a text database and profile for pytest.""" + +import os +from pathlib import Path + +import pytest + +from aiida.common import exceptions +from aiida.common.folders import SandboxFolder +from aiida.engine.utils import instantiate_process +from aiida.manage.manager import get_manager +from aiida.orm import InstalledCode, load_code +from aiida.plugins import CalculationFactory + +pytest_plugins = ["aiida.manage.tests.pytest_fixtures"] # pylint: disable=invalid-name + + +@pytest.fixture(scope="function", autouse=True) +def clear_database_auto(aiida_profile_clean): # pylint: disable=unused-argument + """Automatically clear database in between tests.""" + + +@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. + + Parameters + ---------- + None + + Returns + ------- + Path + Absolute filepath of `tests` folder. + """ + return Path(__file__).resolve() + + +@pytest.fixture(scope="function") +def fixture_sandbox(): + """ + Return a `SandboxFolder` fixture. + + This fixture returns a `SandboxFolder` instance for temporary file operations + within a test function. + + Yields + ------ + SandboxFolder + A `SandboxFolder` instance for temporary file operations. + """ + + with SandboxFolder() as folder: + yield folder + + +@pytest.fixture +def fixture_localhost(aiida_localhost): + """ + Return a localhost `Computer` fixture. + + Parameters + ---------- + aiida_localhost : fixture + A fixture providing a localhost `Computer` instance. + + Returns + ------- + `Computer` + A localhost `Computer` instance. + """ + localhost = aiida_localhost + localhost.set_default_mpiprocs_per_machine(1) + return localhost + + +@pytest.fixture(scope="function") +def janus_code(aiida_local_code_factory): + """ + Fixture to get the janus code. + + Parameters + ---------- + aiida_local_code_factory : fixture + A fixture providing a factory for creating local codes. + + Returns + ------- + `Code` + The janus code instance. + """ + janus_path = os.environ.get("JANUS_PATH") + return aiida_local_code_factory(executable=janus_path, entry_point="janus.sp") + + +@pytest.fixture +def fixture_code(fixture_localhost): + """ + Return an `InstalledCode` instance configured to run calculations of a given + entry point on localhost. + + Parameters + ---------- + fixture_localhost : fixture + A fixture providing a localhost `Computer` instance. + + Notes + ----- + This fixture returns a function that can be called with the entry point name. + If the code with the specified label already exists, it loads and returns it. + Otherwise, it creates a new `InstalledCode` instance with the provided + parameters. + """ + + def _fixture_code(entry_point_name): + """ + Create an `InstalledCode` to run calculations of a given entry point. + + Parameters + ---------- + entry_point_name : str + The entry point name for the calculation plugin. + + Returns + ------- + aiida.orm.nodes.data.code.Code + An `InstalledCode` instance. + """ + label = f"test.{entry_point_name}" + janus_path = os.environ.get("JANUS_PATH") + try: + return load_code(label=label) + except exceptions.NotExistent: + return InstalledCode( + label=label, + computer=fixture_localhost, + filepath_executable=janus_path, + default_calc_job_plugin=entry_point_name, + ) + + return _fixture_code + + +@pytest.fixture +def generate_calc_job(): + """ + Fixture to construct a new `CalcJob` instance and prepare it for submission. + + This fixture returns a function that constructs a new `CalcJob` instance + for testing purposes. + """ + + def _generate_calc_job(folder, entry_point_name, inputs=None): + """ + Generate a mock `CalcInfo` for testing calculation jobs. + + Parameters + ---------- + folder : SandboxFolder + The temporary folder for storing raw input files. + + entry_point_name : str + The entry point name of the `CalcJob` class to be instantiated. + + inputs : Optional[Dict[str, Any]], optional + A dictionary of inputs for the calculation job, by default None. + + Returns + ------- + CalcInfo + The `CalcInfo` object returned by `prepare_for_submission`. + + Notes + ----- + This function constructs a new instance of the specified `CalcJob` class + using the provided inputs, and calls `prepare_for_submission`. + The resulting `CalcInfo` object is returned. + """ + + 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 + + +# Fixture to provide the path to the example file +@pytest.fixture +def example_file_path(): + """ + Fixture to provide the path to the example file. + + Returns: + Path: The path to the example file. + """ + return ( + Path(__file__).resolve().parent.parent + / "examples" + / "calculations" + / "submit_singlepoint.py" + ) diff --git a/tests/data/test_model.py b/tests/data/test_model.py index 8aca7990..e402994f 100644 --- a/tests/data/test_model.py +++ b/tests/data/test_model.py @@ -45,6 +45,7 @@ def test_download_fresh_file(tmp_path): path_test.unlink(missing_ok=True) # Construct a ModelData instance downloading a non-cached file + # pylint:disable=line-too-long model = ModelData.download( url="https://raw.githubusercontent.com/stfc/aiida-mlip/main/tests/input_files/file2.txt", filename="test_download.txt", @@ -71,6 +72,7 @@ def test_no_download_cached_file(tmp_path): (testdir / "test.txt").write_text("file with content\ncontent2\n", encoding="utf-8") # Construct a ModelData instance that should use the cached file + # pylint:disable=line-too-long model = ModelData.download( url="https://raw.githubusercontent.com/stfc/aiida-mlip/main/tests/input_files/file2.txt", cache_dir=tmp_path, diff --git a/tests/input_files/__init__.py b/tests/input_files/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/input_files/file1.txt b/tests/input_files/file1.txt deleted file mode 100644 index 3f2cce17..00000000 --- a/tests/input_files/file1.txt +++ /dev/null @@ -1,2 +0,0 @@ -file with content -content1 diff --git a/tests/input_files/file2.txt b/tests/input_files/file2.txt deleted file mode 100644 index 5bb0129d..00000000 --- a/tests/input_files/file2.txt +++ /dev/null @@ -1,2 +0,0 @@ -file with content -content2 diff --git a/tests/test_calculations.py b/tests/test_calculations.py deleted file mode 100644 index f10f2ac3..00000000 --- a/tests/test_calculations.py +++ /dev/null @@ -1,38 +0,0 @@ -""" Tests for calculations.""" - -import os - -from aiida.engine import run -from aiida.orm import SinglefileData -from aiida.plugins import CalculationFactory, DataFactory - -from . import TEST_DIR - - -def test_process(mlip_code): - """Test running a calculation - note this does not test that the expected outputs are created of output parsing""" - - # Prepare input parameters - DiffParameters = DataFactory("mlip") - parameters = DiffParameters({"ignore-case": True}) - - file1 = SinglefileData(file=os.path.join(TEST_DIR, "input_files", "file1.txt")) - file2 = SinglefileData(file=os.path.join(TEST_DIR, "input_files", "file2.txt")) - - # set up calculation - inputs = { - "code": mlip_code, - "parameters": parameters, - "file1": file1, - "file2": file2, - "metadata": { - "options": {"max_wallclock_seconds": 30}, - }, - } - - result = run(CalculationFactory("mlip"), **inputs) - computed_diff = result["mlip"].get_content() - - assert "content1" in computed_diff - assert "content2" in computed_diff diff --git a/tests/test_cli.py b/tests/test_cli.py deleted file mode 100644 index 6c51dfc4..00000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,38 +0,0 @@ -""" Tests for command line interface.""" - -from click.testing import CliRunner - -from aiida.plugins import DataFactory - -from aiida_mlip.cli import export, list_ - - -# pylint: disable=attribute-defined-outside-init -class TestDataCli: - """Test verdi data cli plugin.""" - - def setup_method(self): - """Prepare nodes for cli tests.""" - DiffParameters = DataFactory("mlip") - self.parameters = DiffParameters({"ignore-case": True}) - self.parameters.store() - self.runner = CliRunner() - - def test_data_diff_list(self): - """Test 'verdi data mlip list' - - Tests that it can be reached and that it lists the node we have set up. - """ - result = self.runner.invoke(list_, catch_exceptions=False) - assert str(self.parameters.pk) in result.output - - def test_data_diff_export(self): - """Test 'verdi data mlip export' - - Tests that it can be reached and that it shows the contents of the node - we have set up. - """ - result = self.runner.invoke( - export, [str(self.parameters.pk)], catch_exceptions=False - ) - assert "ignore-case" in result.output diff --git a/tox.ini b/tox.ini index 7bf9676d..42629170 100644 --- a/tox.ini +++ b/tox.ini @@ -6,9 +6,10 @@ usedevelop=True [testenv:py{39,310,311,312}] description = Run the test suite against Python versions -allowlist_externals = poetry +allowlist_externals = poetry, sh commands_pre = poetry install --no-root --sync -commands = poetry run pytest --cov aiida_mlip --import-mode importlib +# Export path to janus executable installed in tox environment before running pytest +commands = sh -c 'export JANUS_PATH=$(which janus); exec "$@"' _ poetry run pytest --cov aiida_mlip --import-mode importlib [testenv:pre-commit] description = Run the pre-commit checks