From 360863ef75a891e64a84deaac7c0a4d9ebf54824 Mon Sep 17 00:00:00 2001 From: Lorenzo <79980269+bastonero@users.noreply.github.com> Date: Sat, 15 Jun 2024 18:19:25 +0200 Subject: [PATCH] First implementation of CLI --- pyproject.toml | 13 +- src/aiida_vibroscopy/cli/__init__.py | 15 +++ src/aiida_vibroscopy/cli/utils/__init__.py | 3 + src/aiida_vibroscopy/cli/utils/defaults.py | 44 ++++++ src/aiida_vibroscopy/cli/utils/display.py | 42 ++++++ src/aiida_vibroscopy/cli/utils/launch.py | 43 ++++++ src/aiida_vibroscopy/cli/utils/options.py | 126 ++++++++++++++++++ src/aiida_vibroscopy/cli/utils/validate.py | 34 +++++ .../cli/workflows/__init__.py | 16 +++ .../cli/workflows/dielectric/__init__.py | 3 + .../cli/workflows/dielectric/base.py | 56 ++++++++ .../cli/workflows/phonons/__init__.py | 3 + .../cli/workflows/phonons/base.py | 58 ++++++++ .../cli/workflows/phonons/harmonic.py | 73 ++++++++++ .../cli/workflows/spectra/__init__.py | 3 + .../cli/workflows/spectra/iraman.py | 66 +++++++++ src/aiida_vibroscopy/utils/broadenings.py | 2 +- .../workflows/phonons/base.py | 2 +- tests/cli/__init__.py | 2 + tests/cli/conftest.py | 71 ++++++++++ tests/cli/fixtures/overrides/dielectric.yaml | 4 + tests/cli/fixtures/overrides/harmonic.yaml | 3 + .../fixtures/overrides/iraman-spectra.yaml | 3 + tests/cli/fixtures/overrides/phonon.yaml | 2 + tests/cli/test_commands.py | 46 +++++++ tests/cli/workflows/__init__.py | 2 + tests/cli/workflows/dielectric/__init__.py | 2 + tests/cli/workflows/dielectric/test_base.py | 40 ++++++ tests/cli/workflows/phonons/__init__.py | 2 + tests/cli/workflows/phonons/test_base.py | 40 ++++++ tests/cli/workflows/phonons/test_harmonic.py | 40 ++++++ tests/cli/workflows/spectra/__init__.py | 2 + .../workflows/spectra/test_iraman_spectra.py | 40 ++++++ 33 files changed, 895 insertions(+), 6 deletions(-) create mode 100644 src/aiida_vibroscopy/cli/__init__.py create mode 100644 src/aiida_vibroscopy/cli/utils/__init__.py create mode 100644 src/aiida_vibroscopy/cli/utils/defaults.py create mode 100644 src/aiida_vibroscopy/cli/utils/display.py create mode 100644 src/aiida_vibroscopy/cli/utils/launch.py create mode 100644 src/aiida_vibroscopy/cli/utils/options.py create mode 100644 src/aiida_vibroscopy/cli/utils/validate.py create mode 100644 src/aiida_vibroscopy/cli/workflows/__init__.py create mode 100644 src/aiida_vibroscopy/cli/workflows/dielectric/__init__.py create mode 100755 src/aiida_vibroscopy/cli/workflows/dielectric/base.py create mode 100644 src/aiida_vibroscopy/cli/workflows/phonons/__init__.py create mode 100755 src/aiida_vibroscopy/cli/workflows/phonons/base.py create mode 100755 src/aiida_vibroscopy/cli/workflows/phonons/harmonic.py create mode 100644 src/aiida_vibroscopy/cli/workflows/spectra/__init__.py create mode 100755 src/aiida_vibroscopy/cli/workflows/spectra/iraman.py create mode 100644 tests/cli/__init__.py create mode 100644 tests/cli/conftest.py create mode 100644 tests/cli/fixtures/overrides/dielectric.yaml create mode 100644 tests/cli/fixtures/overrides/harmonic.yaml create mode 100644 tests/cli/fixtures/overrides/iraman-spectra.yaml create mode 100644 tests/cli/fixtures/overrides/phonon.yaml create mode 100644 tests/cli/test_commands.py create mode 100644 tests/cli/workflows/__init__.py create mode 100644 tests/cli/workflows/dielectric/__init__.py create mode 100644 tests/cli/workflows/dielectric/test_base.py create mode 100644 tests/cli/workflows/phonons/__init__.py create mode 100644 tests/cli/workflows/phonons/test_base.py create mode 100644 tests/cli/workflows/phonons/test_harmonic.py create mode 100644 tests/cli/workflows/spectra/__init__.py create mode 100644 tests/cli/workflows/spectra/test_iraman_spectra.py diff --git a/pyproject.toml b/pyproject.toml index 6cfabeb..8ef8b7d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,11 @@ classifiers = [ keywords = ['aiida', 'workflows'] requires-python = '>=3.8' dependencies = [ - "aiida-core>=2.2.2,<3.0.0", - "aiida-quantumespresso>=4.3.0", - "aiida-phonopy>=1.1.3", - "phonopy>=2.19.0,<3.0.0", + 'aiida-core>=2.2.2,<3.0.0', + 'aiida-quantumespresso>=4.3.0', + 'aiida-phonopy>=1.1.3', + 'phonopy>=2.19.0,<3.0.0', + 'click~=8.0' ] [project.urls] @@ -54,6 +55,7 @@ docs = [ 'sphinx~=6.2.1', 'sphinx-copybutton~=0.5.2', 'sphinx-book-theme~=1.0.1', + 'sphinx-click~=4.4.0', 'sphinx-design~=0.4.1', 'sphinxcontrib-details-directive~=0.1.0', 'sphinx-autoapi~=3.0.0', @@ -61,6 +63,9 @@ docs = [ 'sphinx-togglebutton', ] +[project.scripts] +aiida-vibroscopy = 'aiida_vibroscopy.cli:cmd_root' + [project.entry-points.'aiida.data'] "vibroscopy.fp" = "aiida_vibroscopy.data.vibro_fp:VibrationalFrozenPhononData" "vibroscopy.vibrational" = "aiida_vibroscopy.data.vibro_lr:VibrationalData" diff --git a/src/aiida_vibroscopy/cli/__init__.py b/src/aiida_vibroscopy/cli/__init__.py new file mode 100644 index 0000000..fc3e828 --- /dev/null +++ b/src/aiida_vibroscopy/cli/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# pylint: disable=wrong-import-position +"""Module for the command line interface.""" +from aiida.cmdline.groups import VerdiCommandGroup +from aiida.cmdline.params import options, types +import click + + +@click.group('aiida-vibroscopy', cls=VerdiCommandGroup, context_settings={'help_option_names': ['-h', '--help']}) +@options.PROFILE(type=types.ProfileParamType(load_profile=True), expose_value=False) +def cmd_root(): + """CLI for the `aiida-vibroscopy` plugin.""" + + +from .workflows import cmd_launch diff --git a/src/aiida_vibroscopy/cli/utils/__init__.py b/src/aiida_vibroscopy/cli/utils/__init__.py new file mode 100644 index 0000000..0e79dab --- /dev/null +++ b/src/aiida_vibroscopy/cli/utils/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +"""Utilities for the command line interface.""" +# pylint: disable=cyclic-import,unused-import,wrong-import-position,import-error diff --git a/src/aiida_vibroscopy/cli/utils/defaults.py b/src/aiida_vibroscopy/cli/utils/defaults.py new file mode 100644 index 0000000..d3f0f3e --- /dev/null +++ b/src/aiida_vibroscopy/cli/utils/defaults.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +"""Module with utitlies for the CLI to generate default values.""" + + +def get_structure(): + """Return a `StructureData` representing bulk silicon. + + The database will first be queried for the existence of a bulk silicon crystal. If this is not the case, one is + created and stored. This function should be used as a default for CLI options that require a `StructureData` node. + This way new users can launch the command without having to construct or import a structure first. This is the + reason that we hardcode a bulk silicon crystal to be returned. More flexibility is not required for this purpose. + + :return: a `StructureData` representing bulk silicon + """ + from aiida.orm import QueryBuilder, StructureData + from ase.spacegroup import crystal + + # Filters that will match any elemental Silicon structure with 2 or less sites in total + filters = { + 'attributes.sites': { + 'of_length': 2 + }, + 'attributes.kinds': { + 'of_length': 1 + }, + 'attributes.kinds.0.symbols.0': 'Si' + } + + builder = QueryBuilder().append(StructureData, filters=filters) + structure = builder.first(flat=True) + + if not structure: + alat = 5.43 + ase_structure = crystal( + 'Si', + [(0, 0, 0)], + spacegroup=227, + cellpar=[alat, alat, alat, 90, 90, 90], + primitive_cell=True, + ) + structure = StructureData(ase=ase_structure) + structure.store() + + return structure.uuid diff --git a/src/aiida_vibroscopy/cli/utils/display.py b/src/aiida_vibroscopy/cli/utils/display.py new file mode 100644 index 0000000..ba5538b --- /dev/null +++ b/src/aiida_vibroscopy/cli/utils/display.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +"""Module with display utitlies for the CLI.""" +import os + +import click + + +def echo_process_results(node): + """Display a formatted table of the outputs registered for the given process node. + + :param node: the `ProcessNode` of a terminated process + """ + from aiida.common.links import LinkType + + class_name = node.process_class.__name__ + outputs = node.base.links.get_outgoing(link_type=(LinkType.CREATE, LinkType.RETURN)).all() + + if hasattr(node, 'dry_run_info'): + # It is a dry-run: get the information and print it + rel_path = os.path.relpath(node.dry_run_info['folder']) + click.echo(f"-> Files created in folder '{rel_path}'") + click.echo(f"-> Submission script filename: '{node.dry_run_info['script_filename']}'") + return + + if node.is_finished and node.exit_message: + state = f'{node.process_state.value} [{node.exit_status}] `{node.exit_message}`' + elif node.is_finished: + state = f'{node.process_state.value} [{node.exit_status}]' + else: + state = node.process_state.value + + click.echo(f'{class_name}<{node.pk}> terminated with state: {state}') + + if not outputs: + click.echo(f'{class_name}<{node.pk}> registered no outputs') + return + + click.echo(f"\n{'Output link':25s} Node pk and type") + click.echo(f"{'-' * 60}") + + for triple in sorted(outputs, key=lambda triple: triple.link_label): + click.echo(f'{triple.link_label:25s} {triple.node.__class__.__name__}<{triple.node.pk}> ') diff --git a/src/aiida_vibroscopy/cli/utils/launch.py b/src/aiida_vibroscopy/cli/utils/launch.py new file mode 100644 index 0000000..93ab138 --- /dev/null +++ b/src/aiida_vibroscopy/cli/utils/launch.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +"""Module with launch utitlies for the CLI.""" +import click + +from .display import echo_process_results + + +def launch_process(process, daemon, **inputs): + """Launch a process with the given inputs. + + If not sent to the daemon, the results will be displayed after the calculation finishes. + + :param process: the process class + :param daemon: boolean, if True will submit to the daemon instead of running in current interpreter + :param inputs: inputs for the process + """ + from aiida.engine import Process, ProcessBuilder, launch + + if isinstance(process, ProcessBuilder): + process_name = process.process_class.__name__ + elif issubclass(process, Process): + process_name = process.__name__ + else: + raise TypeError(f'invalid type for process: {process}') + + if daemon: + node = launch.submit(process, **inputs) + click.echo( + f""" + Submitted {process_name} to the daemon. + Information of the launched process: + * PK :\t{node.pk}> + * UUID:\t{node.uuid}> + Record this information for later usage, or put this node in a Group. + """ + ) + else: + if inputs.get('metadata', {}).get('dry_run', False): + click.echo(f'Running a dry run for {process_name}...') + else: + click.echo(f'Running a {process_name}...') + _, node = launch.run_get_node(process, **inputs) + echo_process_results(node) diff --git a/src/aiida_vibroscopy/cli/utils/options.py b/src/aiida_vibroscopy/cli/utils/options.py new file mode 100644 index 0000000..ba318a4 --- /dev/null +++ b/src/aiida_vibroscopy/cli/utils/options.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""Pre-defined overridable options for commonly used command line interface parameters.""" +# pylint: disable=too-few-public-methods,import-error +from aiida.cmdline.params import types +from aiida.cmdline.params.options import OverridableOption +from aiida.cmdline.utils import decorators +from aiida.common import exceptions +import click + +from . import validate + + +class PseudoFamilyType(types.GroupParamType): + """Subclass of `GroupParamType` in order to be able to print warning with instructions.""" + + def __init__(self, pseudo_types=None, **kwargs): + """Construct a new instance.""" + super().__init__(**kwargs) + self._pseudo_types = pseudo_types + + @decorators.with_dbenv() + def convert(self, value, param, ctx): + """Convert the value to actual pseudo family instance.""" + try: + group = super().convert(value, param, ctx) + except click.BadParameter: + try: + from aiida.orm import load_group + load_group(value) + except exceptions.NotExistent: # pylint: disable=try-except-raise + raise + + raise click.BadParameter( # pylint: disable=raise-missing-from + f'`{value}` is not of a supported pseudopotential family type.\nTo install a supported ' + 'pseudofamily, use the `aiida-pseudo` plugin. See the following link for detailed instructions:\n\n' + ' https://github.com/aiidateam/aiida-quantumespresso#pseudopotentials' + ) + + if self._pseudo_types is not None and group.pseudo_type not in self._pseudo_types: + pseudo_types = ', '.join(self._pseudo_types) + raise click.BadParameter( + f'family `{group.label}` contains pseudopotentials of the wrong type `{group.pseudo_type}`.\nOnly the ' + f'following types are supported: {pseudo_types}' + ) + + return group + + +PW_CODE = OverridableOption( + '--pw', + 'pw_code', + type=types.CodeParamType(entry_point='quantumespresso.pw'), + required=True, + help='The code to use for the pw.x executable.' +) + +PHONOPY_CODE = OverridableOption( + '--phonopy', + 'phonopy_code', + type=types.CodeParamType(entry_point='phonopy.phonopy'), + required=True, + help='The code to use for the phonopy executable.' +) + +PSEUDO_FAMILY = OverridableOption( + '-F', + '--pseudo-family', + type=PseudoFamilyType(sub_classes=('aiida.groups:pseudo.family',), pseudo_types=('pseudo.upf',)), + required=False, + help='Select a pseudopotential family, identified by its label.' +) + +STRUCTURE = OverridableOption( + '-S', + '--structure', + type=types.DataParamType(sub_classes=('aiida.data:core.structure',)), + help='A StructureData node identified by its ID or UUID.' +) + +KPOINTS_MESH = OverridableOption( + '-k', + '--kpoints-mesh', + 'kpoints_mesh', + nargs=6, + type=click.Tuple([int, int, int, float, float, float]), + show_default=True, + callback=validate.validate_kpoints_mesh, + help='The number of points in the kpoint mesh along each basis vector and the offset. ' + 'Example: `-k 2 2 2 0 0 0`. Specify `0.5 0.5 0.5` for the offset if you want to result ' + 'in the equivalent Quantum ESPRESSO pw.x `1 1 1` shift.' +) + +PARENT_FOLDER = OverridableOption( + '-P', + '--parent-folder', + 'parent_folder', + type=types.DataParamType(sub_classes=('aiida.data:core.remote',)), + show_default=True, + required=False, + help='A parent remote folder node identified by its ID or UUID.' +) + +DAEMON = OverridableOption( + '-D', + '--daemon', + is_flag=True, + default=True, + show_default=True, + help='Submit the process to the daemon instead of running it and waiting for it to finish.' +) + +OVERRIDES = OverridableOption( + '-o', + '--overrides', + type=click.File('r'), + required=False, + help='The filename or filepath containing the overrides, in YAML format.' +) + +PROTOCOL = OverridableOption( + '-p', + '--protocol', + type=click.STRING, + required=False, + help='Select the protocol that defines the accuracy of the calculation.' +) diff --git a/src/aiida_vibroscopy/cli/utils/validate.py b/src/aiida_vibroscopy/cli/utils/validate.py new file mode 100644 index 0000000..e729ee6 --- /dev/null +++ b/src/aiida_vibroscopy/cli/utils/validate.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +"""Utility functions for validation of command line interface parameter inputs.""" +from aiida.cmdline.utils import decorators +import click + + +@decorators.with_dbenv() +def validate_kpoints_mesh(ctx, param, value): + """Command line option validator for a kpoints mesh tuple. + + The value should be a tuple of three positive integers out of which a KpointsData object will be created with a mesh + equal to the tuple. + + :param ctx: internal context of the click.command + :param param: the click Parameter, i.e. either the Option or Argument to which the validator is hooked up + :param value: a tuple of three positive integers + :returns: a KpointsData instance + """ + # pylint: disable=unused-argument + from aiida.orm import KpointsData + + if not value: + return None + + if any(not isinstance(integer, int) for integer in value[:3]) or any(int(i) <= 0 for i in value[:3]): + raise click.BadParameter('all values of the tuple should be positive greater than zero integers') + + try: + kpoints = KpointsData() + kpoints.set_kpoints_mesh(value[:3], value[3:]) + except ValueError as exception: + raise click.BadParameter(f'failed to create a KpointsData mesh out of {value}\n{exception}') + + return kpoints diff --git a/src/aiida_vibroscopy/cli/workflows/__init__.py b/src/aiida_vibroscopy/cli/workflows/__init__.py new file mode 100644 index 0000000..5b69e5b --- /dev/null +++ b/src/aiida_vibroscopy/cli/workflows/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# pylint: disable=cyclic-import,reimported,unused-import,wrong-import-position,import-error +"""Module with CLI commands for the various work chain implementations.""" +from .. import cmd_root + + +@cmd_root.group('launch') +def cmd_launch(): + """Launch workflows.""" + + +from .dielectric.base import launch_workflow +from .phonons.base import launch_workflow +from .phonons.harmonic import launch_workflow +# Import the sub commands to register them with the CLI +from .spectra.iraman import launch_workflow diff --git a/src/aiida_vibroscopy/cli/workflows/dielectric/__init__.py b/src/aiida_vibroscopy/cli/workflows/dielectric/__init__.py new file mode 100644 index 0000000..5945fc4 --- /dev/null +++ b/src/aiida_vibroscopy/cli/workflows/dielectric/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# pylint: disable=cyclic-import,unused-import,wrong-import-position,import-error +"""Module with CLI commands for various dielectric workflows.""" diff --git a/src/aiida_vibroscopy/cli/workflows/dielectric/base.py b/src/aiida_vibroscopy/cli/workflows/dielectric/base.py new file mode 100755 index 0000000..40a0fcf --- /dev/null +++ b/src/aiida_vibroscopy/cli/workflows/dielectric/base.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +"""Command line scripts to launch a `DielectricWorkChain` for testing and demonstration purposes.""" +# pylint: disable=import-error +from aiida.cmdline.utils import decorators +import click +import yaml + +from .. import cmd_launch +from ...utils import defaults, launch, options + + +@cmd_launch.command('dielectric') +@options.PW_CODE() +@options.STRUCTURE(default=defaults.get_structure) +@options.PROTOCOL(type=click.Choice(['fast', 'moderate', 'precise']), default='moderate', show_default=True) +@options.PSEUDO_FAMILY() +@options.KPOINTS_MESH(show_default=False) +@options.OVERRIDES() +@options.DAEMON() +@decorators.with_dbenv() +def launch_workflow(pw_code, structure, protocol, pseudo_family, kpoints_mesh, overrides, daemon): + """Run an `DielectricWorkChain`. + + It computes dielectric, Born charges, Raman and non-linear optical susceptibility + tensors for a given structure. + """ + from aiida.plugins import WorkflowFactory # pyliny: disable=import-error + + entry_point_name = 'vibroscopy.dielectric' + + if overrides: + overrides = yaml.safe_load(overrides) + + if pseudo_family: + if overrides: + overrides.setdefault('scf', {})['pseudo_family'] = pseudo_family.label + else: + overrides = { + 'scf': { + 'pseudo_family': pseudo_family.label + }, + } + + builder = WorkflowFactory(entry_point_name).get_builder_from_protocol( + code=pw_code, + structure=structure, + protocol=protocol, + overrides=overrides, + ) + + if kpoints_mesh: + builder.pop('kpoints_parallel_distance') + builder.scf.pop('kpoints_distance') + builder.scf.kpoints = kpoints_mesh + + launch.launch_process(builder, daemon) diff --git a/src/aiida_vibroscopy/cli/workflows/phonons/__init__.py b/src/aiida_vibroscopy/cli/workflows/phonons/__init__.py new file mode 100644 index 0000000..3cb9240 --- /dev/null +++ b/src/aiida_vibroscopy/cli/workflows/phonons/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# pylint: disable=cyclic-import,unused-import,wrong-import-position,import-error +"""Module with CLI commands for various phonon workflows.""" diff --git a/src/aiida_vibroscopy/cli/workflows/phonons/base.py b/src/aiida_vibroscopy/cli/workflows/phonons/base.py new file mode 100755 index 0000000..be7d384 --- /dev/null +++ b/src/aiida_vibroscopy/cli/workflows/phonons/base.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""Command line scripts to launch a `PhononWorkChain` for testing and demonstration purposes.""" +from aiida.cmdline.utils import decorators +import click +import yaml + +from .. import cmd_launch +from ...utils import defaults, launch, options + + +@cmd_launch.command('phonon') +@options.PW_CODE() +@options.STRUCTURE(default=defaults.get_structure) +@options.PROTOCOL(type=click.Choice(['fast', 'moderate', 'precise']), default='moderate', show_default=True) +@options.PSEUDO_FAMILY() +@options.KPOINTS_MESH(show_default=False) +@options.PHONOPY_CODE(required=False) +@options.OVERRIDES() +@options.DAEMON() +@decorators.with_dbenv() +def launch_workflow(pw_code, structure, protocol, pseudo_family, kpoints_mesh, phonopy_code, overrides, daemon): + """Run an `PhononWorkChain`. + + It computes the force constants in the harmonic approximation. + + .. note:: this workflow does NOT computer non-analytical constants (dielectric and + Born effective charges tensors). Only the finite displacements of atoms. + """ + from aiida.plugins import WorkflowFactory + + entry_point_name = 'vibroscopy.phonons.phonon' + + if overrides: + overrides = yaml.safe_load(overrides) + + if pseudo_family: + if overrides: + overrides.setdefault('scf', {})['pseudo_family'] = pseudo_family.label + else: + overrides = { + 'scf': { + 'pseudo_family': pseudo_family.label + }, + } + + builder = WorkflowFactory(entry_point_name).get_builder_from_protocol( + pw_code=pw_code, + structure=structure, + protocol=protocol, + phonopy_code=phonopy_code, + overrides=overrides, + ) + + if kpoints_mesh: + builder.scf.pop('kpoints_distance') + builder.scf.kpoints = kpoints_mesh + + launch.launch_process(builder, daemon) diff --git a/src/aiida_vibroscopy/cli/workflows/phonons/harmonic.py b/src/aiida_vibroscopy/cli/workflows/phonons/harmonic.py new file mode 100755 index 0000000..3d1a905 --- /dev/null +++ b/src/aiida_vibroscopy/cli/workflows/phonons/harmonic.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +"""Command line scripts to launch a `HarmonicWorkChain` for testing and demonstration purposes.""" +from aiida.cmdline.utils import decorators +import click +import yaml + +from .. import cmd_launch +from ...utils import defaults, launch, options + + +@cmd_launch.command('harmonic') +@options.PW_CODE() +@options.STRUCTURE(default=defaults.get_structure) +@options.PROTOCOL(type=click.Choice(['fast', 'moderate', 'precise']), default='moderate', show_default=True) +@options.PSEUDO_FAMILY() +@options.KPOINTS_MESH(show_default=False) +@options.OVERRIDES() +@options.PHONOPY_CODE(required=False) +@options.DAEMON() +@decorators.with_dbenv() +def launch_workflow(pw_code, structure, protocol, pseudo_family, kpoints_mesh, overrides, phonopy_code, daemon): + """Run a `HarmonicWorkChain`. + + It computes force constants in the harmonic approximation, + dielectric, Born charges, Raman and non-linear optical susceptibility tensors, + to account for non-analytical behaviour of the dynamical matrix at small q-vectors. + + The output can then be used to quickly post-process and get phonons related properties, + such as IR absorption/reflectivity and Raman spectra in different experimental settings, + phonon dispersion . + """ + from aiida.plugins import WorkflowFactory + + entry_point_name = 'vibroscopy.phonons.harmonic' + + if overrides: + overrides = yaml.safe_load(overrides) + + if pseudo_family: + if overrides: + overrides.setdefault('dielectric', {}).setdefault('scf', {})['pseudo_family'] = pseudo_family.label + overrides.setdefault('phonon', {}).setdefault('scf', {})['pseudo_family'] = pseudo_family.label + else: + overrides = { + 'dielectric': { + 'scf': { + 'pseudo_family': pseudo_family.label + }, + }, + 'phonon': { + 'scf': { + 'pseudo_family': pseudo_family.label + }, + }, + } + + builder = WorkflowFactory(entry_point_name).get_builder_from_protocol( + pw_code=pw_code, + structure=structure, + protocol=protocol, + overrides=overrides, + phonopy_code=phonopy_code, + ) + + if kpoints_mesh: + builder.dielectric.pop('kpoints_parallel_distance') + builder.dielectric.scf.pop('kpoints_distance') + builder.dielectric.scf.kpoints = kpoints_mesh + + builder.phonon.scf.pop('kpoints_distance') + builder.phonon.scf.kpoints = kpoints_mesh + + launch.launch_process(builder, daemon) diff --git a/src/aiida_vibroscopy/cli/workflows/spectra/__init__.py b/src/aiida_vibroscopy/cli/workflows/spectra/__init__.py new file mode 100644 index 0000000..5c40094 --- /dev/null +++ b/src/aiida_vibroscopy/cli/workflows/spectra/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +# pylint: disable=cyclic-import,unused-import,wrong-import-position,import-error +"""Module with CLI commands for various spectra workflows.""" diff --git a/src/aiida_vibroscopy/cli/workflows/spectra/iraman.py b/src/aiida_vibroscopy/cli/workflows/spectra/iraman.py new file mode 100755 index 0000000..b18d748 --- /dev/null +++ b/src/aiida_vibroscopy/cli/workflows/spectra/iraman.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +"""Command line scripts to launch a `IRamanSpectraWorkChain` for testing and demonstration purposes.""" +from aiida.cmdline.utils import decorators +import click +import yaml + +from .. import cmd_launch +from ...utils import defaults, launch, options + + +@cmd_launch.command('iraman-spectra') +@options.PW_CODE() +@options.STRUCTURE(default=defaults.get_structure) +@options.PROTOCOL(type=click.Choice(['fast', 'moderate', 'precise']), default='moderate', show_default=True) +@options.PSEUDO_FAMILY() +@options.KPOINTS_MESH(show_default=False) +@options.OVERRIDES() +@options.DAEMON() +@decorators.with_dbenv() +def launch_workflow(pw_code, structure, protocol, pseudo_family, kpoints_mesh, overrides, daemon): + """Run an `IRamanSpectraWorkChain`. + + It computes force constants, dielectric, Born charges, Raman and non-linear optical + susceptibility tensors via finite displacements and finite fields. The output can then + be used to quickly post-process and get the IR absorption/reflectivity and Raman spectra + in different experimental settings. + """ + from aiida.plugins import WorkflowFactory + + if overrides: + overrides = yaml.safe_load(overrides) + + if pseudo_family: + if overrides: + overrides.setdefault('dielectric', {}).setdefault('scf', {})['pseudo_family'] = pseudo_family.label + overrides.setdefault('phonon', {}).setdefault('scf', {})['pseudo_family'] = pseudo_family.label + else: + overrides = { + 'dielectric': { + 'scf': { + 'pseudo_family': pseudo_family.label + }, + }, + 'phonon': { + 'scf': { + 'pseudo_family': pseudo_family.label + }, + }, + } + + builder = WorkflowFactory('vibroscopy.spectra.iraman').get_builder_from_protocol( + code=pw_code, + structure=structure, + protocol=protocol, + overrides=overrides, + ) + + if kpoints_mesh: + builder.dielectric.pop('kpoints_parallel_distance') + builder.dielectric.scf.pop('kpoints_distance') + builder.dielectric.scf.kpoints = kpoints_mesh + + builder.phonon.scf.pop('kpoints_distance') + builder.phonon.scf.kpoints = kpoints_mesh + + launch.launch_process(builder, daemon) diff --git a/src/aiida_vibroscopy/utils/broadenings.py b/src/aiida_vibroscopy/utils/broadenings.py index f7960f4..b95b6c8 100644 --- a/src/aiida_vibroscopy/utils/broadenings.py +++ b/src/aiida_vibroscopy/utils/broadenings.py @@ -98,7 +98,7 @@ def voigt_profile(x_range: np.ndarray, peak: float, intensity: float, gamma_lore eta_I = 0. eta_P = 0. # - for index, _ in enumerate(list_a): + for index, i in enumerate(list_a): # pylint: disable=unnecessary-list-index-lookup i = index #fortran convention w_G = w_G - rho * list_a[index] * (rho**i) w_L = w_L - (1. - rho) * list_b[index] * (rho**i) diff --git a/src/aiida_vibroscopy/workflows/phonons/base.py b/src/aiida_vibroscopy/workflows/phonons/base.py index 2ee704c..be67a5a 100644 --- a/src/aiida_vibroscopy/workflows/phonons/base.py +++ b/src/aiida_vibroscopy/workflows/phonons/base.py @@ -289,7 +289,7 @@ def set_ctx_variables(self): """Set `is_magnetic` and hubbard-related context variables.""" parameters = self.inputs.scf.pw.parameters.get_dict() nspin = parameters.get('SYSTEM', {}).get('nspin', 1) - self.ctx.is_magnetic = (nspin != 1) + self.ctx.is_magnetic = nspin != 1 self.ctx.is_insulator = True self.ctx.plus_hubbard = False self.ctx.old_plus_hubbard = False diff --git a/tests/cli/__init__.py b/tests/cli/__init__.py new file mode 100644 index 0000000..598c7b7 --- /dev/null +++ b/tests/cli/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Test module for the command line interface.""" diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 0000000..8cf1a7b --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# pylint: disable=redefined-outer-name +"""Fixtures for the command line interface.""" +import pytest + + +def mock_launch_process(*_, **__): + """Mock the :meth:`~aiida_learn.cli.utilslaunch.launch_process` to be a no-op.""" + return + + +@pytest.fixture +def filepath_cli_fixture(filepath_tests): + """Return the filepath for CLI fixtures.""" + from pathlib import Path + return Path(filepath_tests, 'cli', 'fixtures') + + +@pytest.fixture +def run_cli_command(): + """Run a `click` command with the given options. + + The call will raise if the command triggered an exception or the exit code returned is non-zero. + """ + + def _run_cli_command(command, options=None, raises=None): + """Run the command and check the result. + + :param command: the command to invoke + :param options: the list of command line options to pass to the command invocation + :param raises: optionally an exception class that is expected to be raised + """ + import traceback + + from click.testing import CliRunner + + runner = CliRunner() + result = runner.invoke(command, options or []) + + if raises is not None: + assert result.exception is not None, result.output + assert result.exit_code != 0 + else: + assert result.exception is None, ''.join(traceback.format_exception(*result.exc_info)) + assert result.exit_code == 0, result.output + + result.output_lines = [line.strip() for line in result.output.split('\n') if line.strip()] + + return result + + return _run_cli_command + + +@pytest.fixture +def run_cli_process_launch_command(run_cli_command, monkeypatch): + """Run a process launch command with the given options. + + The call will raise if the command triggered an exception or the exit code returned is non-zero. + + :param command: the command to invoke + :param options: the list of command line options to pass to the command invocation + :param raises: optionally an exception class that is expected to be raised + """ + + def _inner(command, options=None, raises=None): + """Run the command and check the result.""" + from aiida_learn.cli.utils import launch + monkeypatch.setattr(launch, 'launch_process', mock_launch_process) + return run_cli_command(command, options, raises) + + return _inner diff --git a/tests/cli/fixtures/overrides/dielectric.yaml b/tests/cli/fixtures/overrides/dielectric.yaml new file mode 100644 index 0000000..9e5a4e1 --- /dev/null +++ b/tests/cli/fixtures/overrides/dielectric.yaml @@ -0,0 +1,4 @@ +scf: + parameters: + SYSTEM: + ecutwfc: 10 diff --git a/tests/cli/fixtures/overrides/harmonic.yaml b/tests/cli/fixtures/overrides/harmonic.yaml new file mode 100644 index 0000000..8da91f1 --- /dev/null +++ b/tests/cli/fixtures/overrides/harmonic.yaml @@ -0,0 +1,3 @@ +phonon: + scf: + kpoints_distance: 0.2 diff --git a/tests/cli/fixtures/overrides/iraman-spectra.yaml b/tests/cli/fixtures/overrides/iraman-spectra.yaml new file mode 100644 index 0000000..8da91f1 --- /dev/null +++ b/tests/cli/fixtures/overrides/iraman-spectra.yaml @@ -0,0 +1,3 @@ +phonon: + scf: + kpoints_distance: 0.2 diff --git a/tests/cli/fixtures/overrides/phonon.yaml b/tests/cli/fixtures/overrides/phonon.yaml new file mode 100644 index 0000000..577eb05 --- /dev/null +++ b/tests/cli/fixtures/overrides/phonon.yaml @@ -0,0 +1,2 @@ +scf: + kpoints_distance: 0.2 diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py new file mode 100644 index 0000000..d064d86 --- /dev/null +++ b/tests/cli/test_commands.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +"""Tests for CLI commands.""" +from __future__ import annotations + +import subprocess + +from aiida_pseudo.cli import cmd_root +import click +import pytest + + +def recurse_commands(command: click.Command, parents: list[str] = None): + """Recursively return all subcommands that are part of ``command``. + + :param command: The click command to start with. + :param parents: A list of strings that represent the parent commands leading up to the current command. + :returns: A list of strings denoting the full path to the current command. + """ + if isinstance(command, click.Group): + for command_name in command.commands: + subcommand = command.get_command(None, command_name) + if parents is not None: + subparents = parents + [command.name] + else: + subparents = [command.name] + yield from recurse_commands(subcommand, subparents) + + if parents is not None: + yield parents + [command.name] + else: + yield [command.name] + + +@pytest.mark.parametrize('command', recurse_commands(cmd_root)) +@pytest.mark.parametrize('help_option', ('--help', '-h')) +def test_commands_help_option(command, help_option): + """Test the help options for all subcommands of the CLI. + + The usage of ``subprocess.run`` is on purpose because using :meth:`click.Context.invoke`, which is used by the + ``run_cli_command`` fixture that should usually be used in testing CLI commands, does not behave exactly the same + compared to a direct invocation on the command line. The invocation through ``invoke`` does not go through all the + parent commands and so might not get all the necessary initializations. + """ + result = subprocess.run(command + [help_option], check=False, capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert 'Usage:' in result.stdout diff --git a/tests/cli/workflows/__init__.py b/tests/cli/workflows/__init__.py new file mode 100644 index 0000000..b960e61 --- /dev/null +++ b/tests/cli/workflows/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Test module for the workflows CLI.""" diff --git a/tests/cli/workflows/dielectric/__init__.py b/tests/cli/workflows/dielectric/__init__.py new file mode 100644 index 0000000..1a42c6d --- /dev/null +++ b/tests/cli/workflows/dielectric/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Test module for the dielectric workflows CLI.""" diff --git a/tests/cli/workflows/dielectric/test_base.py b/tests/cli/workflows/dielectric/test_base.py new file mode 100644 index 0000000..6ef8a8a --- /dev/null +++ b/tests/cli/workflows/dielectric/test_base.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""Tests for the ``launch dielectric`` command.""" +from pathlib import Path + +from aiida_vibroscopy.cli.workflows.dielectric.base import launch_workflow + + +# yapf: disable +def test_command_dielectric(run_cli_process_launch_command, fixture_code, filepath_cli_fixture): + """Test invoking the launch command with only required inputs.""" + code = fixture_code('quantumespresso.pw').store() + options = [ + '--pw', code.full_label, + '-o', str(Path(filepath_cli_fixture, 'overrides', 'dielectric.yaml')), + '-p', 'fast', + '-F', 'SSSP/1.3/PBEsol/efficiency', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-F', 'SSSP/1.3/PBEsol/efficiency', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-o', str(Path(filepath_cli_fixture, 'overrides', 'dielectric.yaml')), + '-k', '2', '2', '2', '0.5', '0.5', '0.5', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-k', '2', '2', '2', '0.5', '0.5', '0.5', + ] + run_cli_process_launch_command(launch_workflow, options=options) diff --git a/tests/cli/workflows/phonons/__init__.py b/tests/cli/workflows/phonons/__init__.py new file mode 100644 index 0000000..4878b54 --- /dev/null +++ b/tests/cli/workflows/phonons/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Test module for the phonon workflows CLI.""" diff --git a/tests/cli/workflows/phonons/test_base.py b/tests/cli/workflows/phonons/test_base.py new file mode 100644 index 0000000..1e4c62f --- /dev/null +++ b/tests/cli/workflows/phonons/test_base.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""Tests for the ``launch phonon`` command.""" +from pathlib import Path + +from aiida_vibroscopy.cli.workflows.phonons.base import launch_workflow + + +# yapf: disable +def test_command_phonon(run_cli_process_launch_command, fixture_code, filepath_cli_fixture): + """Test invoking the launch command with only required inputs.""" + code = fixture_code('quantumespresso.pw').store() + options = [ + '--pw', code.full_label, + '-o', str(Path(filepath_cli_fixture, 'overrides', 'phonon.yaml')), + '-p', 'fast', + '-F', 'SSSP/1.3/PBEsol/efficiency', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-F', 'SSSP/1.3/PBEsol/efficiency', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-o', str(Path(filepath_cli_fixture, 'overrides', 'phonon.yaml')), + '-k', '2', '2', '2', '0.5', '0.5', '0.5', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-k', '2', '2', '2', '0.5', '0.5', '0.5', + ] + run_cli_process_launch_command(launch_workflow, options=options) diff --git a/tests/cli/workflows/phonons/test_harmonic.py b/tests/cli/workflows/phonons/test_harmonic.py new file mode 100644 index 0000000..14a8da9 --- /dev/null +++ b/tests/cli/workflows/phonons/test_harmonic.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""Tests for the ``launch harmonic`` command.""" +from pathlib import Path + +from aiida_vibroscopy.cli.workflows.phonons.harmonic import launch_workflow + + +# yapf: disable +def test_command_harmonic(run_cli_process_launch_command, fixture_code, filepath_cli_fixture): + """Test invoking the launch command with only required inputs.""" + code = fixture_code('quantumespresso.pw').store() + options = [ + '--pw', code.full_label, + '-o', str(Path(filepath_cli_fixture, 'overrides', 'harmonic.yaml')), + '-p', 'fast', + '-F', 'SSSP/1.3/PBEsol/efficiency', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-F', 'SSSP/1.3/PBEsol/efficiency', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-o', str(Path(filepath_cli_fixture, 'overrides', 'harmonic.yaml')), + '-k', '2', '2', '2', '0.5', '0.5', '0.5', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-k', '2', '2', '2', '0.5', '0.5', '0.5', + ] + run_cli_process_launch_command(launch_workflow, options=options) diff --git a/tests/cli/workflows/spectra/__init__.py b/tests/cli/workflows/spectra/__init__.py new file mode 100644 index 0000000..6dc7d41 --- /dev/null +++ b/tests/cli/workflows/spectra/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Test module for the spectra workflows CLI.""" diff --git a/tests/cli/workflows/spectra/test_iraman_spectra.py b/tests/cli/workflows/spectra/test_iraman_spectra.py new file mode 100644 index 0000000..0c2d9da --- /dev/null +++ b/tests/cli/workflows/spectra/test_iraman_spectra.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""Tests for the ``launch iraman-spectra`` command.""" +from pathlib import Path + +from aiida_vibroscopy.cli.workflows.spectra.iraman import launch_workflow + + +# yapf: disable +def test_command_iraman_spectra(run_cli_process_launch_command, fixture_code, filepath_cli_fixture): + """Test invoking the launch command with only required inputs.""" + code = fixture_code('quantumespresso.pw').store() + options = [ + '--pw', code.full_label, + '-o', str(Path(filepath_cli_fixture, 'overrides', 'iraman-spectra.yaml')), + '-p', 'fast', + '-F', 'SSSP/1.3/PBEsol/efficiency', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-F', 'SSSP/1.3/PBEsol/efficiency', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-o', str(Path(filepath_cli_fixture, 'overrides', 'iraman-spectra.yaml')), + '-k', '2', '2', '2', '0.5', '0.5', '0.5', + ] + run_cli_process_launch_command(launch_workflow, options=options) + + options = [ + '--pw', code.full_label, + '-p', 'fast', + '-k', '2', '2', '2', '0.5', '0.5', '0.5', + ] + run_cli_process_launch_command(launch_workflow, options=options)