Skip to content

Commit

Permalink
Add the LammpsRawCalculation and LammpsRawParser plugins (#80)
Browse files Browse the repository at this point in the history
* Add the `LammpsRawCalculation` and `LammpsRawParser` plugins

These `CalcJob` and `Parser` plugins provide a bare-bones interface to
running any LAMMPS calculation. The `CalcJob` just requires the `script`
input which takes a complete LAMMPS input script. The `files` namespace
can be used to add additional input files to the working directory.

* Docs: Add `CalcJobNode` to the nitpick exceptions
  • Loading branch information
sphuber authored Jun 7, 2023
1 parent 2b98d35 commit a796cae
Show file tree
Hide file tree
Showing 12 changed files with 542 additions and 0 deletions.
121 changes: 121 additions & 0 deletions aiida_lammps/calculations/raw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Plugin with minimal interface to run LAMMPS."""
import shutil

from aiida import orm
from aiida.common.datastructures import CalcInfo, CodeInfo
from aiida.common.folders import Folder
from aiida.engine import CalcJob


class LammpsRawCalculation(CalcJob):
"""Plugin with minimal interface to run LAMMPS."""

FILENAME_INPUT = "input.in"
FILENAME_OUTPUT = "lammps.out"
FILENAME_LOG = "lammps.log"

@classmethod
def define(cls, spec):
super().define(spec)
spec.input(
"script",
valid_type=orm.SinglefileData,
help="Complete input script to use. If specified, `structure`, `potential` and `parameters` are ignored.",
)
spec.input_namespace(
"files",
valid_type=orm.SinglefileData,
required=False,
help="Optional files that should be written to the working directory.",
)
spec.input(
"filenames",
valid_type=orm.Dict,
serializer=orm.to_aiida_type,
required=False,
help="Optional namespace to specify with which filenames the files of ``files`` input should be written.",
)
spec.inputs["metadata"]["options"][
"input_filename"
].default = cls.FILENAME_INPUT
spec.inputs["metadata"]["options"][
"output_filename"
].default = cls.FILENAME_OUTPUT
spec.inputs["metadata"]["options"]["parser_name"].default = "lammps.raw"
spec.inputs.validator = cls.validate_inputs

spec.output(
"results",
valid_type=orm.Dict,
required=True,
help="The data extracted from the lammps log file",
)
spec.exit_code(
351,
"ERROR_LOG_FILE_MISSING",
message="the file with the lammps log was not found",
invalidates_cache=True,
)
spec.exit_code(
1001,
"ERROR_PARSING_LOGFILE",
message="parsing the log file has failed.",
)

@classmethod
def validate_inputs(cls, value, ctx):
"""Validate the top-level inputs namespace."""
# The filename with which the file is written to the working directory is defined by the ``filenames`` input
# namespace, falling back to the filename of the ``SinglefileData`` node if not defined.
overrides = value["filenames"].get_dict() if "filenames" in value else {}
filenames = [
overrides.get(key, node.filename)
for key, node in value.get("files", {}).items()
]

if len(filenames) != len(set(filenames)):
return (
f"The list of filenames of the ``files`` input is not unique: {filenames}. Use the ``filenames`` input "
"namespace to explicitly define unique filenames for each file."
)

def prepare_for_submission(self, folder: Folder) -> CalcInfo:
"""Prepare the calculation for submission.
:param folder: A temporary folder on the local file system.
:returns: A :class:`aiida.common.datastructures.CalcInfo` instance.
"""
filename_log = self.FILENAME_LOG
filename_input = self.inputs.metadata.options.input_filename
filename_output = self.inputs.metadata.options.output_filename
filenames = (
self.inputs["filenames"].get_dict() if "filenames" in self.inputs else {}
)
provenance_exclude_list = []

with folder.open(filename_input, "w") as handle:
handle.write(self.inputs.script.get_content())

for key, node in self.inputs.get("files", {}).items():

# The filename with which the file is written to the working directory is defined by the ``filenames`` input
# namespace, falling back to the filename of the ``SinglefileData`` node if not defined.
filename = filenames.get(key, node.filename)

with folder.open(filename, "wb") as target:
with node.open(mode="rb") as source:
shutil.copyfileobj(source, target)

provenance_exclude_list.append(filename)

codeinfo = CodeInfo()
codeinfo.cmdline_params = ["-in", filename_input, "-log", filename_log]
codeinfo.code_uuid = self.inputs.code.uuid
codeinfo.stdout_name = self.inputs.metadata.options.output_filename

calcinfo = CalcInfo()
calcinfo.provenance_exclude_list = provenance_exclude_list
calcinfo.retrieve_list = [filename_output, filename_log]
calcinfo.codes_info = [codeinfo]

return calcinfo
47 changes: 47 additions & 0 deletions aiida_lammps/parsers/raw.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Base parser for LAMMPS log output."""
import time

from aiida import orm
from aiida.parsers.parser import Parser

from aiida_lammps.calculations.raw import LammpsRawCalculation
from aiida_lammps.parsers.parse_raw import parse_logfile


class LammpsRawParser(Parser):
"""Base parser for LAMMPS log output."""

def parse(self, **kwargs):
"""Parse the contents of the output files stored in the ``retrieved`` output node."""
retrieved = self.retrieved
retrieved_filenames = retrieved.base.repository.list_object_names()
filename_log = LammpsRawCalculation.FILENAME_LOG

if filename_log not in retrieved_filenames:
return self.exit_codes.ERROR_LOG_FILE_MISSING

parsed_data = parse_logfile(
file_contents=retrieved.base.repository.get_object_content(filename_log)
)
if parsed_data is None:
return self.exit_codes.ERROR_PARSING_LOGFILE

global_data = parsed_data["global"]
results = {"compute_variables": global_data}

if "total_wall_time" in global_data:
try:
parsed_time = time.strptime(global_data["total_wall_time"], "%H:%M:%S")
except ValueError:
pass
else:
total_wall_time_seconds = (
parsed_time.tm_hour * 3600
+ parsed_time.tm_min * 60
+ parsed_time.tm_sec
)
global_data["total_wall_time_seconds"] = total_wall_time_seconds

self.out("results", orm.Dict(results))

return None
71 changes: 71 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""
from __future__ import annotations

import collections
import os
import pathlib
import shutil
Expand All @@ -11,6 +12,7 @@

from aiida import orm
from aiida.common.datastructures import CalcInfo
from aiida.common.links import LinkType
from aiida.engine import CalcJob
import numpy as np
import pytest
Expand Down Expand Up @@ -58,6 +60,12 @@ def pytest_report_header(config):
]


@pytest.fixture
def filepath_tests() -> pathlib.Path:
"""Return the path to the tests folder."""
return pathlib.Path(__file__).resolve().parent / "tests"


@pytest.fixture(scope="function")
def db_test_app(aiida_profile, pytestconfig):
"""Clear the database after each test."""
Expand Down Expand Up @@ -117,6 +125,69 @@ def factory(
return factory


@pytest.fixture
def generate_calc_job_node(filepath_tests, aiida_computer_local, tmp_path):
"""Create and return a :class:`aiida.orm.CalcJobNode` instance."""

def flatten_inputs(inputs, prefix=""):
"""Flatten inputs recursively like :meth:`aiida.engine.processes.process::Process._flatten_inputs`."""
flat_inputs = []
for key, value in inputs.items():
if isinstance(value, collections.abc.Mapping):
flat_inputs.extend(flatten_inputs(value, prefix=prefix + key + "__"))
else:
flat_inputs.append((prefix + key, value))
return flat_inputs

def factory(
entry_point: str,
test_name: str,
inputs: dict = None,
retrieve_temporary_list: list[str] | None = None,
):
"""Create and return a :class:`aiida.orm.CalcJobNode` instance."""
node = orm.CalcJobNode(
computer=aiida_computer_local(),
process_type=f"aiida.calculations:{entry_point}",
)

if inputs:
for link_label, input_node in flatten_inputs(inputs):
input_node.store()
node.base.links.add_incoming(
input_node, link_type=LinkType.INPUT_CALC, link_label=link_label
)

node.store()

filepath_retrieved = (
filepath_tests
/ "parsers"
/ "fixtures"
/ entry_point.split(".")[-1]
/ test_name
)

retrieved = orm.FolderData()
retrieved.base.repository.put_object_from_tree(filepath_retrieved)
retrieved.base.links.add_incoming(
node, link_type=LinkType.CREATE, link_label="retrieved"
)
retrieved.store()

if retrieve_temporary_list:
for pattern in retrieve_temporary_list:
for filename in filepath_retrieved.glob(pattern):
filepath = tmp_path / filename.relative_to(filepath_retrieved)
filepath.write_bytes(filename.read_bytes())

return node, tmp_path

return node

return factory


@pytest.fixture(scope="function")
def get_structure_data():
"""get the structure data for the simulation."""
Expand Down
1 change: 1 addition & 0 deletions docs/source/nitpick-exceptions
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ py:class aiida_lammps.parsers.parse_raw.trajectory.TRAJ_BLOCK
py:exc aiida.common.StoringNotAllowed
py:class Logger
py:class AttributeDict
py:class CalcJobNode
72 changes: 72 additions & 0 deletions examples/launch_lammps_raw_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Run a LAMMPS calculation with additional input files
The example input script is taken from https://www.lammps.org/inputs/in.rhodo.txt and is an example benchmark script for
the official benchmarks of LAMMPS. It is a simple MD simulation of a protein. It requires an additional input file in
the working directory ``data.rhodo``. This example shows how to add such additional input files.
"""
import io
import textwrap

from aiida import engine, orm, plugins

script = orm.SinglefileData(
io.StringIO(
textwrap.dedent(
"""
# Rhodopsin model
units real
neigh_modify delay 5 every 1
atom_style full
bond_style harmonic
angle_style charmm
dihedral_style charmm
improper_style harmonic
pair_style lj/charmm/coul/long 8.0 10.0
pair_modify mix arithmetic
kspace_style pppm 1e-4
read_data data.rhodo
fix 1 all shake 0.0001 5 0 m 1.0 a 232
fix 2 all npt temp 300.0 300.0 100.0 &
z 0.0 0.0 1000.0 mtk no pchain 0 tchain 1
special_bonds charmm
thermo 50
thermo_style multi
timestep 2.0
run 100
"""
)
)
)
data = orm.SinglefileData(
io.StringIO(
textwrap.dedent(
"""
LAMMPS data file from restart file: timestep = 5000, procs = 1
32000 atoms
27723 bonds
40467 angles
56829 dihedrals
1034 impropers
...
"""
)
)
)

builder = plugins.CalculationFactory("lammps.raw").get_builder()
builder.code = orm.load_code("lammps-23.06.2022@localhost")
builder.script = script
builder.files = {"data": data}
builder.filenames = {"data": "data.rhodo"}
builder.metadata.options = {"resources": {"num_machines": 1}}
_, node = engine.run_get_node(builder)

print(f"Calculation node: {submission_node}")
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,11 @@ docs = [

[project.entry-points."aiida.calculations"]
"lammps.base" = "aiida_lammps.calculations.base:LammpsBaseCalculation"
"lammps.raw" = "aiida_lammps.calculations.raw:LammpsRawCalculation"

[project.entry-points."aiida.parsers"]
"lammps.base" = "aiida_lammps.parsers.base:LammpsBaseParser"
"lammps.raw" = "aiida_lammps.parsers.raw:LammpsRawParser"

[project.entry-points."aiida.data"]
"lammps.potential" = "aiida_lammps.data.potential:LammpsPotentialData"
Expand Down
Empty file added tests/calculations/__init__.py
Empty file.
Loading

0 comments on commit a796cae

Please sign in to comment.