Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

aiida-cp2k cli #105

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
- name: Install python dependencies
run: |
pip install --upgrade pip
pip install -e .[pre-commit,test,docs]
pip install -e .[cli,pre-commit,test,docs]
reentry scan
- name: Run pre-commit
run: |
Expand All @@ -89,7 +89,7 @@ jobs:
- name: Install python dependencies
run: |
pip install --upgrade pip
pip install -e .[docs,test]
pip install -e .[cli,docs,test]
reentry scan

- name: Build docs
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends cp2k

# Install aiida-cp2k plugin.
COPY . aiida-cp2k
RUN pip install ./aiida-cp2k[pre-commit,test,docs]
RUN pip install ./aiida-cp2k[pre-commit,test,docs,cli]

# Install coverals.
RUN pip install coveralls
Expand Down
22 changes: 22 additions & 0 deletions aiida_cp2k/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-

# pylint: disable=wrong-import-position,wildcard-import
"""Base command line interface module to wire up subcommands and loading the profile."""

import click
import click_completion

from aiida.cmdline.params import options, types

# Activate the completion of parameter types provided by the click_completion package
click_completion.init()


@click.group('aiida-cp2k', context_settings={'help_option_names': ['-h', '--help']})
@options.PROFILE(type=types.ProfileParamType(load_profile=True))
def cmd_root(profile): # pylint: disable=unused-argument
"""CLI for the `aiida-cp2k` plugin."""


from .data import cmd_structure
from .workflows import cmd_workflow
15 changes: 15 additions & 0 deletions aiida_cp2k/cli/data/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# -*- coding: utf-8 -*-

# pylint: disable=cyclic-import,unused-import,wrong-import-position
"""Module with CLI commands for various data types."""

from .. import cmd_root


@cmd_root.group('data')
def cmd_data():
"""Commands to create and inspect data nodes."""


# Import the sub commands to register them with the CLI
from .structure import cmd_structure
36 changes: 36 additions & 0 deletions aiida_cp2k/cli/data/structure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
"""Command line utilities to create and inspect `StructureData` nodes from CP2K input files."""

import click

from aiida.cmdline.params import options
from aiida.cmdline.utils import decorators, echo

from . import cmd_data
from ..utils.structure import structure_from_cp2k_inp


@cmd_data.group('structure')
def cmd_structure():
"""Commands to create and inspect `StructureData` nodes from CP2K input."""


@cmd_structure.command('import')
@click.argument('filename', type=click.File('r'))
@options.DRY_RUN()
@decorators.with_dbenv()
def cmd_import(filename, dry_run):
"""Import a `StructureData` from a CP2K input file."""

try:
structure = structure_from_cp2k_inp(filename)
except ValueError as exc:
echo.echo_critical(str(exc))

formula = structure.get_formula()

if dry_run:
echo.echo_success('parsed structure with formula {}'.format(formula))
else:
structure.store()
echo.echo_success('parsed and stored StructureData<{}> with formula {}'.format(structure.pk, formula))
64 changes: 64 additions & 0 deletions aiida_cp2k/cli/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Some helpers for writing CLI functions"""

# pylint: disable=import-outside-toplevel

import os

import click

# the following 2 methods originated from aiiida-quantumespresso


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.cmdline.utils.common import get_node_info

class_name = node.process_class.__name__

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("-> Files created in folder '{}'".format(rel_path))
click.echo("-> Submission script filename: '{}'".format(node.dry_run_info['script_filename']))
return

if node.is_finished and node.exit_message:
state = '{} [{}] `{}`'.format(node.process_state.value, node.exit_status, node.exit_message)
elif node.is_finished:
state = '{} [{}]'.format(node.process_state.value, node.exit_status)
else:
state = node.process_state.value

click.echo("{}<{}> terminated with state: {}]\n".format(class_name, node.pk, state))
click.echo(get_node_info(node))


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 launch, Process, ProcessBuilder

if isinstance(process, ProcessBuilder):
process_name = process.process_class.__name__
elif issubclass(process, Process):
process_name = process.__name__
else:
raise TypeError('invalid type for process: {}'.format(process))

if daemon:
node = launch.submit(process, **inputs)
click.echo('Submitted {}<{}> to the daemon'.format(process_name, node.pk))
else:
if inputs.get('metadata', {}).get('dry_run', False):
click.echo('Running a dry run for {}...'.format(process_name))
else:
click.echo('Running a {}...'.format(process_name))
_, node = launch.run_get_node(process, **inputs)
echo_process_results(node)
31 changes: 31 additions & 0 deletions aiida_cp2k/cli/utils/cp2k_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
"""Helpers to setup command line options"""

from aiida.cmdline.params import types
from aiida.cmdline.params.options import OverridableOption

DAEMON = OverridableOption("-d",
"--daemon",
is_flag=True,
default=False,
show_default=True,
help="Submit the process to the daemon instead of running it directly.")

STRUCTURE = OverridableOption("-s",
"--structure",
type=types.DataParamType(sub_classes=("aiida.data:structure",)),
help="StructureData node.")

MAX_NUM_MACHINES = OverridableOption('-m',
'--max-num-machines',
type=int,
default=1,
show_default=True,
help='The maximum number of machines (nodes) to use for the calculations.')

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add the number of CPUs per node parameter.

MAX_WALLCLOCK_SECONDS = OverridableOption('-w',
'--max-wallclock-seconds',
type=int,
default=1800,
show_default=True,
help='the maximum wallclock time in seconds to set for the calculations.')
94 changes: 94 additions & 0 deletions aiida_cp2k/cli/utils/structure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# -*- coding: utf-8 -*-
"""Helper functions for building CLI functions dealing with structures"""

import re

import numpy as np


def structure_from_cp2k_inp(filename):
"""Create an AiiDA StructureData from a structure inside a CP2K input file"""
# pylint: disable=import-outside-toplevel,invalid-name,too-many-locals,too-many-statements,too-many-branches

from cp2k_input_tools.parser import CP2KInputParser
from aiida.orm.nodes.data.structure import StructureData, Kind, Site, symop_fract_from_ortho
from ase.geometry.cell import cell_to_cellpar, cellpar_to_cell
import ase.io

# the following was taken from aiida-quantumespresso
VALID_ELEMENTS_REGEX = re.compile(
r"""
^(
H | He |
Li | Be | B | C | N | O | F | Ne |
Na | Mg | Al | Si | P | S | Cl | Ar |
K | Ca | Sc | Ti | V | Cr | Mn | Fe | Co | Ni | Cu | Zn | Ga | Ge | As | Se | Br | Kr |
Rb | Sr | Y | Zr | Nb | Mo | Tc | Ru | Rh | Pd | Ag | Cd | In | Sn | Sb | Te | I | Xe |
Cs | Ba | Hf | Ta | W | Re | Os | Ir | Pt | Au | Hg | Tl | Pb | Bi | Po | At | Rn |
Fr | Ra | Rf | Db | Sg | Bh | Hs | Mt |
La | Ce | Pr | Nd | Pm | Sm | Eu | Gd | Tb | Dy | Ho | Er | Tm | Yb | Lu | # Lanthanides
Ac | Th | Pa | U | Np | Pu | Am | Cm | Bk | Cf | Es | Fm | Md | No | Lr | # Actinides
)
""", re.VERBOSE | re.IGNORECASE)

parser = CP2KInputParser()
tree = parser.parse(filename)
force_eval_no = -1
force_eval = None

for force_eval_no, force_eval in enumerate(tree['+force_eval']):
try:
cell = force_eval['+subsys']['+cell']
kinds = force_eval['+subsys']['+kind']
break # for now grab the first &COORD found
except KeyError:
continue
else:
raise ValueError('no CELL, or KIND found in the given input file')

# CP2K can get its cell information in two ways:
# - A, B, C: cell vectors
# - ABC: scaling of cell vectors, ALPHA_BETA_GAMMA: angles between the cell vectors (optional)

if 'a' in cell:
unit_cell = np.array([cell['a'], cell['b'], cell['c']]) # unit vectors given
cellpar = cell_to_cellpar(unit_cell)
elif 'abc' in cell:
cellpar = cell['abc'] + cell.get('alpha_beta_gamma', [90., 90., 90.])
unit_cell = cellpar_to_cell(cellpar)
else:
raise ValueError('incomplete &CELL section')

pbc = [c in cell.get('periodic', 'XYZ') for c in 'XYZ']

structure = StructureData(cell=unit_cell, pbc=pbc)

if force_eval['+subsys'].get('+coord', {}).get('scaled', False):
tmat = symop_fract_from_ortho(cellpar)
else:
tmat = np.eye(3)

for kind in kinds:
name = kind['_']

try:
# prefer the ELEMENT keyword, fallback to extracting from name
element = kind.get('element', VALID_ELEMENTS_REGEX.search(name)[0])
except TypeError:
raise ValueError('ELEMENT not set and unable to extract from {name}'.format(name=name))

structure.append_kind(Kind(name=name, symbols=element))

try:
structfn = force_eval["+subsys"]["+topology"]["coord_file_name"]
atoms = ase.io.read(structfn)

for name, position in zip(atoms.symbols, atoms.positions):
structure.append_site(Site(kind_name=name, position=tmat @ np.array(position)))

except KeyError:
for name, position, _ in parser.coords(force_eval_no):
# positions can be scaled, apply transformation matrix
structure.append_site(Site(kind_name=name, position=tmat @ np.array(position)))

return structure
18 changes: 18 additions & 0 deletions aiida_cp2k/cli/workflows/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# pylint: disable=cyclic-import,unused-import,wrong-import-position
"""Base workflow commands and sub-commands"""

from .. import cmd_root


@cmd_root.group("workflow")
def cmd_workflow():
"""Commands to launch and interact with workflows."""


@cmd_workflow.group("launch")
def cmd_launch():
"""Launch workflow."""


from .base import cmd_launch_workflow
Loading