Python wrapping code for gmxapi mdrun bindings.
authorM. Eric Irrgang <ericirrgang@gmail.com>
Thu, 29 Aug 2019 18:28:43 +0000 (21:28 +0300)
committerMark Abraham <mark.j.abraham@gmail.com>
Tue, 3 Sep 2019 07:52:42 +0000 (09:52 +0200)
Expose the mdrun bindings to the Python interface.

Updates workflow.py and context.py from 0.0.7.4 tagged release of external project.

Refs #2893
Refs #2912
Refs #3027

Change-Id: I44a66646eceb191a2ec755169671af326f22526a

python_packaging/sample_restraint/tests/test_binding.py
python_packaging/src/gmxapi/__init__.py
python_packaging/src/gmxapi/exceptions.py
python_packaging/src/gmxapi/export_context.cpp
python_packaging/src/gmxapi/simulation/.gitattributes [deleted file]
python_packaging/src/gmxapi/simulation/__init__.py
python_packaging/src/gmxapi/simulation/context.py
python_packaging/src/gmxapi/simulation/mdrun.py [new file with mode: 0644]
python_packaging/src/gmxapi/simulation/workflow.py
python_packaging/src/setup.py
python_packaging/src/test/test_mdrun.py

index ba96040a8ff3b2752330a4c16e044308555be2c5..e2b058692931786f7172d74d88e6b14754f3c00e 100644 (file)
@@ -10,7 +10,7 @@ import logging
 import os
 
 import gmxapi as gmx
-from gmxapi.simulation.context import ParallelArrayContext
+from gmxapi.simulation.context import Context
 from gmxapi.simulation.workflow import WorkElement, from_tpr
 from gmxapi import version as gmx_version
 import pytest
@@ -30,6 +30,8 @@ logger = logging.getLogger()
 
 
 def test_import():
+    # Suppress inspection warning outside of testing context.
+    # noinspection PyUnresolvedReferences
     import myplugin
     assert myplugin
 
@@ -64,7 +66,7 @@ def test_ensemble_potential_nompi(tpr_filename):
     potential.name = "ensemble_restraint"
     md.add_dependency(potential)
 
-    context = ParallelArrayContext(md)
+    context = Context(md)
 
     with context as session:
         session.run()
@@ -108,6 +110,6 @@ def test_ensemble_potential_withmpi(tpr_filename):
     potential.name = "ensemble_restraint"
     md.add_dependency(potential)
 
-    context = ParallelArrayContext(md)
+    context = Context(md)
     with context as session:
         session.run()
index 1f6177cb2b9dbb3fdf01f0cb4bf47bd152a3c629..88a9151990a0b50a2cd9cfdb39fd1ef70a1a428e 100644 (file)
@@ -48,6 +48,7 @@ __all__ = ['commandline_operation',
            'logger',
            'logical_not',
            'make_constant',
+           'mdrun',
            'ndarray',
            'subgraph',
            'while_loop',
@@ -64,6 +65,7 @@ from .datamodel import NDArray
 from .datamodel import ndarray
 from .operation import concatenate_lists, join_arrays, logical_not, make_constant
 from .commandline import commandline_operation
+from .simulation import mdrun
 # TODO: decide where this lives
 from .operation import subgraph
 # TODO: decide where this lives
index 1bda7be8576b5e7bcc5551f206a578817a67445c..25f53261fec2715655e4a9d11c0b23279ef7177c 100644 (file)
@@ -1,3 +1,4 @@
+#
 # This file is part of the GROMACS molecular simulation package.
 #
 # Copyright (c) 2019, by the GROMACS development team, led by
@@ -86,6 +87,15 @@ class NotImplementedError(Error):
     # to avoid releasing incomplete implementations (e.g. overlooked "To do"s)
 
 
+class FeatureNotAvailableError(Error):
+    """Requested feature not available in the current environment.
+
+    This exception will usually indicate an issue with the user's environment or
+    run time details. There may be a missing optional dependency, which should
+    be specified in the exception message.
+    """
+
+
 class ProtocolError(Error):
     """Unexpected API behavior or protocol violation.
 
index e2e10597fcbd7f1f23280804c1fc67f35a17b213..93cd76c509a4177cc5656c7c8d5891ee24785908 100644 (file)
@@ -71,7 +71,7 @@ namespace py = pybind11;
  * http://manual.gromacs.org/current/onlinehelp/gmx-mdrun.html#options
  */
 static void setMDArgs(std::vector<std::string>* mdargs,
-                      py::dict                  params)
+                      const py::dict           &params)
 {
     mdargs->clear();
     if (params.contains("grid"))
@@ -117,7 +117,7 @@ static void setMDArgs(std::vector<std::string>* mdargs,
     }
     if (params.contains("pme_threads_per_rank"))
     {
-        auto val = py::cast<std::string>(py::str(params["pme_threads_per_rank"]));
+        auto val = py::cast<std::string>(py::str(params["threads_per_pme_rank"]));
         mdargs->emplace_back("-ntomp_pme");
         mdargs->emplace_back(val);
     }
@@ -158,7 +158,7 @@ void export_context(py::module &m)
     py::class_ < MDArgs, std::unique_ptr < MDArgs>> mdargs(m, "MDArgs");
     mdargs.def(py::init(), "Create an empty MDArgs object.");
     mdargs.def("set",
-               [](MDArgs* self, py::dict params){ setMDArgs(self, params); },
+               [](MDArgs* self, const py::dict &params){ setMDArgs(self, params); },
                "Assign parameters in MDArgs from Python dict.");
 
     // Export execution context class
diff --git a/python_packaging/src/gmxapi/simulation/.gitattributes b/python_packaging/src/gmxapi/simulation/.gitattributes
deleted file mode 100644 (file)
index 7242fa4..0000000
+++ /dev/null
@@ -1,5 +0,0 @@
-# Some files in this directory are borrowed from the Kasson Lab gmxapi project
-# release 0.0.7.4 and used in accordance with
-# https://github.com/kassonlab/gmxapi/blob/v0.0.7.4/LICENSE
-context.py !filter=copyright
-workflow.py !filter=copyright
index 18ad0c30d451e1c4835ae0e1ab4b8a83e96c2d50..825349250d0be40bcac02e4a1aecc292c49422d7 100644 (file)
 #
 # To help us fund GROMACS development, we humbly ask that you cite
 # the research papers on the package. Check out http://www.gromacs.org.
+"""GROMACS simulation subpackage for gmxapi.
+
+Provides operations for configuring and running molecular simulations.
+"""
+
+__all__ = ['mdrun']
+
+from .mdrun import mdrun
index afb674f10ca63d0f7c39d2f9595405d21935fe12..5eeecdf9c613e0fb99a7c88085a9ecc3e5df1f6d 100644 (file)
@@ -1,11 +1,44 @@
+#
+# This file is part of the GROMACS molecular simulation package.
+#
+# Copyright (c) 2019, by the GROMACS development team, led by
+# Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
+# and including many others, as listed in the AUTHORS file in the
+# top-level source directory and at http://www.gromacs.org.
+#
+# GROMACS is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public License
+# as published by the Free Software Foundation; either version 2.1
+# of the License, or (at your option) any later version.
+#
+# GROMACS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with GROMACS; if not, see
+# http://www.gnu.org/licenses, or write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
+#
+# If you want to redistribute modifications to GROMACS, please
+# consider that scientific software is very special. Version
+# control is crucial - bugs must be traceable. We will be happy to
+# consider code for inclusion in the official distribution, but
+# derived work must not be called official GROMACS. Details are found
+# in the README & COPYING files - if they are missing, get the
+# official version at http://www.gromacs.org.
+#
+# To help us fund GROMACS development, we humbly ask that you cite
+# the research papers on the package. Check out http://www.gromacs.org.
+#
+# This file is based on the Kasson Lab gmxapi project release 0.0.7.4.
+# https://github.com/kassonlab/gmxapi/blob/v0.0.7.4/src/gmx/context.py
+# https://github.com/kassonlab/gmxapi/blob/v0.0.7.4/LICENSE
 """
 Execution Context
 =================
 """
-from __future__ import absolute_import
-from __future__ import division
-from __future__ import print_function
-from __future__ import unicode_literals
 
 __all__ = ['Context']
 
@@ -14,15 +47,13 @@ import os
 import warnings
 import tempfile
 
-from gmx import exceptions
-from gmx import logging
-from gmx import status
-import gmx.core as gmxapi
-
+from gmxapi import exceptions
+from gmxapi import logger as root_logger
+import gmxapi._gmxapi as _gmxapi
 
 # Module-level logger
-logger = logging.getLogger(__name__)
-logger.info('Importing gmx.context')
+logger = root_logger.getChild('context')
+logger.info('Importing {}'.format(__name__))
 
 
 def _load_tpr(self, element):
@@ -102,7 +133,7 @@ def _md(context, element):
                     and hasattr(dag, 'add_edge')
                     and hasattr(dag, 'graph')
                     and hasattr(dag, 'nodes')):
-                raise gmx.exceptions.TypeError("dag argument does not have a DiGraph interface.")
+                raise exceptions.ProtocolError("Argument 'dag' must provide the DiGraph interface.")
             name = self.name
             dag.add_node(name)
             for neighbor in self.input_nodes:
@@ -124,17 +155,17 @@ def _md(context, element):
                     # altered input.
                     _, temp_filename = tempfile.mkstemp(suffix='.tpr')
                     logger.debug('Updating input. Using temp file {}'.format(temp_filename))
-                    gmxapi.copy_tprfile(source=infile[rank],
-                                          destination=temp_filename,
-                                          end_time=self.runtime_params['end_time'])
+                    _gmxapi.rewrite_tprfile(source=infile[rank],
+                                         destination=temp_filename,
+                                         end_time=self.runtime_params['end_time'])
                     tpr_file = temp_filename
                 else:
                     tpr_file = infile[rank]
 
                 logger.info('Loading TPR file: {}'.format(tpr_file))
-                system = gmxapi.from_tpr(tpr_file)
+                system = _gmxapi.from_tpr(tpr_file)
                 dag.nodes[name]['system'] = system
-                mdargs = gmxapi.MDArgs()
+                mdargs = _gmxapi.MDArgs()
                 mdargs.set(self.runtime_params)
                 # Workaround to give access to plugin potentials used in a context.
                 pycontext = element.workspec._context
@@ -436,170 +467,39 @@ def _get_ensemble_update(context):
     return functor
 
 
-class _libgromacsContext(object):
-    """Low level API to libgromacs library context provides Python context manager.
-
-    Binds to a workflow and manages computation resources.
-
-    Attributes:
-        workflow (:obj:`gmx.workflow.WorkSpec`): bound workflow to be executed.
-
-    Example:
-        >>> with _libgromacsContext(my_workflow) as session: # doctest: +SKIP
-        ...    session.run()
-
-    Things are still fluid, but what we might do is have all of the WorkSpec operations that are supported
-    by a given Context to correspond to member functions in the Context, a SessionBuilder, or Session. In
-    any case, the operations allow a Context implementation to transform a work specification into a
-    directed acyclic graph of schedulable work.
-    """
-    # The Context is the appropriate entity to own or mediate access to an appropriate logging facility,
-    # but right now we are using the module-level Python logger.
-    # Reference https://github.com/kassonlab/gmxapi/issues/135
-    def __init__(self, workflow=None):
-        """Create new context bound to the provided workflow, if any.
-
-        Args:
-            workflow (gmx.workflow.WorkSpec) work specification object to bind.
-
-        """
-        self._session = None
-        self.__workflow = workflow
-
-    @property
-    def workflow(self):
-        return self.__workflow
-
-    @workflow.setter
-    def workflow(self, workflow):
-        """Before accepting a workflow, the context must check whether it can interpret the work specification."""
-
-        self.__workflow = workflow
-
-    @classmethod
-    def check_workspec(cls, workspec, raises=False):
-        """Check the validity of the work specification in this Context.
-
-        Args:
-            workspec: work specification to check
-            raises: Boolean (default False)
-
-        If raises == True, raises exceptions for problems found in the work specification.
-
-        Returns:
-            True if workspec is processable in this Context, else False.
-        """
-        from gmx.workflow import workspec_version, get_source_elements, WorkElement
-        # initialize return value.
-        is_valid = True
-        # Check compatibility
-        if workspec.version != workspec_version:
-            is_valid = False
-            if raises:
-                raise exceptions.ApiError('Incompatible workspec version.')
-        # Check that Elements are uniquely identifiable.
-        elements = dict()
-        for element in workspec.elements:
-            if element.name is not None and element.name not in elements:
-                elements[element.name] = element
-            else:
-                is_valid = False
-                if raises:
-                    raise exceptions.ApiError('WorkSpec must contain uniquely named elements.')
-        # Check that the specification is complete. There must be at least one source element and all
-        # dependencies must be fulfilled.
-        sources = set([element.name for element in get_source_elements(workspec)])
-        if len(sources) < 1:
-            is_valid = False
-            if raises:
-                raise exceptions.ApiError('WorkSpec must contain at least one source element')
-        return is_valid
-
-
-    def __enter__(self):
-        """Implement Python context manager protocol.
-
-        Returns:
-            runnable session object.
-        """
-        if self._session is not None:
-            raise exceptions.Error('Already running.')
-        # The API runner currently has an implicit context.
-        try:
-            # launch() with no arguments is deprecated.
-            # Ref: https://github.com/kassonlab/gmxapi/issues/124
-            self._session = self.workflow.launch()
-        except:
-            self._session = None
-            raise
-        return self._session
-
-    def __exit__(self, exception_type, exception_value, traceback):
-        """Implement Python context manager protocol.
-
-        Closing a session should not produce Python exceptions. Instead, exit
-        state is accessible through API objects like Status.
-        For evolving design points, see
-        - https://github.com/kassonlab/gmxapi/issues/41
-        - https://github.com/kassonlab/gmxapi/issues/121
-        """
-        self._session.close()
-        self._session = None
-        return False
-
-
-class DefaultContext(_libgromacsContext):
-    """ Produce an appropriate context for the work and compute environment.
-
-    Deprecated:
-        Use gmx.context.get_context() to find an appropriate high-level API
-        context. For lower-level access to the library that does not employ the
-        full API Context abstraction, but instead explicitly uses a local
-        libgromacs instance, a replacement still needs to be devised. It may
-        have this same interface, but the name and scoping of DefaultContext is
-        misleading.
-    """
-    def __init__(self, work):
-        # There is very little context abstraction at this point...
-        warnings.warn("Behavior of DefaultContext is unspecified starting in gmxapi 0.0.8.", DeprecationWarning)
-        super(DefaultContext, self).__init__(work)
-
 class Context(object):
     """Manage an array of simulation work executing in parallel.
 
-    This is the first implementation of a new style of Context class that has some extra abstraction
-    and uses the new WorkSpec idea.
+    .. deprecated:: 0.0.7
 
-    Additional facilities are available to elements of the array members.
+    This execution context implementation is imported from the gmxapi 0.0.7
+    package for GROMACS 2019 and does not conform to the protocols of gmxapi 0.1+.
+    It is used internally to support legacy code.
 
-      * array element corresponding to work in the current sub-context
-      * "global" resources managed by the ParallelArrayContext
+    The following features are subject to be changed or removed without further
+    notice.
 
     Attributes:
         work :obj:`gmx.workflow.WorkSpec`: specification of work to be performed when a session is launched.
         rank : numerical index of the current worker in a running session (None if not running)
-        work_width : Minimum width needed for the parallelism required by the array of work being executed.
+        work_width : Detected number of co-scheduled work elements.
         elements : dictionary of references to elements of the workflow.
 
     `rank`, `work_width`, and `elements` are empty or None until the work is processed, as during session launch.
 
-    Example:
-        Use ``mpiexec -n 2 python -m mpi4py myscript.py`` to run two jobs at the same time.
-        In this example the jobs are identical. In myscript.py:
-
-        >>> import gmx
-        >>> import gmx.core
-        >>> from gmx.data import tpr_filename # Get a test tpr filename
-        >>> work = gmx.workflow.from_tpr([tpr_filename, tpr_filename])
-        >>> gmx.run(work)
+    To run multiple simulations at a time, whether ensembles or independent
+    simulations, Python should be invoked in an MPI environment with `mpi4py`.
+    The Context can then use one MPI rank per simulation. It is recommended that
+    GROMACS is compiled without MPI in this case. (Thread-MPI is recommended.)
+    MPI domain decomposition is not explicitly supported with this Context
+    implementation.
 
     Example:
 
-        >>> import gmx
-        >>> import gmx.core
-        >>> from gmx.data import tpr_filename # Get a test tpr filename
-        >>> work = gmx.workflow.from_tpr([tpr_filename, tpr_filename])
-        >>> context = gmx.context.get_context(work)
+        >>> from gmxapi.simulation.context import get_context
+        >>> from gmxapi.simulation.workflow import from_tpr
+        >>> work = from_tpr([tpr_filename, tpr_filename])
+        >>> context = get_context(work)
         >>> with context as session:
         ...    session.run()
         ...    # The session is one abstraction too low to know what rank it is. It lets the spawning context manage
@@ -611,6 +511,11 @@ class Context(object):
         ...    assert(os.path.exists(output_path))
         ...    print('Worker {} produced {}'.format(rank, output_path))
 
+    Warning:
+        Do not run a gmxapi script in an MPI environment without `mpi4py`. gmxapi
+        will not be able to determine that other processes are running the same
+        script.
+
     Implementation notes:
 
     To produce a running session, the Context __enter__() method is called, according to the Python context manager
@@ -628,7 +533,7 @@ class Context(object):
 
     Each node has a `launch()` method. When the session is entered, the `launch()` method is called for each node in
     dependency order. The launch method returns either a callable (`run()` function) or None, raising an exception in
-    case of an error. The sequence of non-None callables is stored by the Session. When Session.run() is called, the
+    case of an error. The sequence of callables is stored by the Session. When Session.run() is called, the
     sequence of callables is called in order. If StopIteration is raised by the callable, it is removed from the sequence.
     The sequence is processed repeatedly until there are no more callables.
 
@@ -664,7 +569,7 @@ class Context(object):
         """
 
         # self.__context_array = list([Context(work_element) for work_element in work])
-        from gmx.workflow import WorkSpec
+        from .workflow import WorkSpec
 
         # Until better Session abstraction exists at the Python level, a
         # _session_communicator attribute will be added to and removed from the
@@ -677,8 +582,8 @@ class Context(object):
         self.__workdir_list = workdir_list
 
         self._session = None
-        # This may not belong here. Is it confusing for the Context to have both global and local properties?
-        # Alternatively, maybe a trivial `property` that gets the rank from a bound session, if any.
+        # Note: this attribute is a detail of MPI-based Contexts. Client access
+        # is subject to change.
         self.rank = None
 
         # `work_width` notes the required width of an array of synchronous tasks to perform the specified work.
@@ -697,10 +602,9 @@ class Context(object):
         #
         # The gmxapi namespace of operations should be consistent with a specified universal set of functionalities
         self.__operations['gmxapi'] = {'md': lambda element : _md(self, element),
-                                       # 'global_data' : shared_data_maker,
                                       }
         # Even if TPR file loading were to become a common and stable enough operation to be specified in
-        # and API, it is unlikely to be implemented by any code outside of GROMACS, so let's not clutter
+        # an API, it is unlikely to be implemented by any code outside of GROMACS, so let's not clutter
         # a potentially more universal namespace.
         self.__operations['gromacs'] = {'load_tpr': lambda element : _load_tpr(self, element),
                                        }
@@ -723,7 +627,11 @@ class Context(object):
         # This setter must be called after the operations map has been populated.
         self.work = work
 
-        self._api_object = gmxapi.Context()
+        try:
+            self._api_object = _gmxapi.Context()
+        except Exception as e:
+            logger.error('Got exception when trying to create default library context: ' + str(e))
+            raise exceptions.ApiError('Uncaught exception in API object creation.') from e
 
     @property
     def work(self):
@@ -743,7 +651,7 @@ class Context(object):
 
         For discussion on error handling, see https://github.com/kassonlab/gmxapi/issues/125
         """
-        from gmx.workflow import WorkSpec, WorkElement
+        from .workflow import WorkSpec, WorkElement
         if work is None:
             return
 
@@ -809,6 +717,7 @@ class Context(object):
         self.__work = workspec
 
     def add_operation(self, namespace, operation, get_builder):
+        # noinspection PyUnresolvedReferences
         """Add a builder factory to the operation map.
 
         Extends the known operations of the Context by mapping an operation in a namespace to a function
@@ -863,7 +772,16 @@ class Context(object):
         return self.__ensemble_update(self, send, recv, tag)
 
     def __enter__(self):
-        """Implement Python context manager protocol, producing a Session for the specified work in this Context.
+        """Implement Python context manager protocol, producing a Session.
+
+        The work specified in this Context is inspected to build a directed
+        acyclic dependency graph (DAG). A Session is launched after reconciling
+        the configured work with available computing resources. Each time the
+        `run()` method of the Session is called, the graph is executed, and any
+        nodes that have no more work to do are pruned from the graph.
+
+        In practice, there are not currently any types of work implemented that
+        do not complete on the first pass.
 
         Returns:
             Session object that can be run and/or inspected.
@@ -945,6 +863,7 @@ class Context(object):
                 builders[name].add_subscriber(new_builder)
             builders[element.name] = new_builder
             builder_sequence.append(element.name)
+            logger.debug("Appended {} to builder sequence.".format(element.name))
 
         # Call the builders in dependency order
         # Note: session_communicator is available, but ensemble_communicator has not been created yet.
@@ -955,6 +874,7 @@ class Context(object):
             logger.info("Building {}".format(builder))
             logger.debug("Has build attribute {}.".format(builder.build))
             builder.build(graph)
+            logger.debug("Built.")
         self.work_width = graph.graph['width']
 
         # Prepare working directories. This should probably be moved to some aspect of the Session and either
@@ -994,7 +914,7 @@ class Context(object):
             self.workdir = self.__workdir_list[self.rank]
             if os.path.exists(self.workdir):
                 if not os.path.isdir(self.workdir):
-                    raise exceptions.FileError('{} is not a valid working directory.'.format(self.workdir))
+                    raise exceptions.Error('{} is not a valid working directory.'.format(self.workdir))
             else:
                 os.mkdir(self.workdir)
             os.chdir(self.workdir)
@@ -1033,9 +953,8 @@ class Context(object):
                         logger.debug("Closing node: {}".format(close))
                         close()
                     # Workaround for bug gmxapi-214
-                    if not gmxapi.has_feature('0.0.7-bugfix-https://github.com/kassonlab/gmxapi/issues/214'):
-                        context._api_object = gmxapi.Context()
-
+                    if not _gmxapi.has_feature('0.0.7-bugfix-https://github.com/kassonlab/gmxapi/issues/214'):
+                        context._api_object = _gmxapi.Context()
 
             self._session = Session(runners, closers)
         else:
@@ -1045,12 +964,12 @@ class Context(object):
             class NullSession(object):
                 def run(self):
                     logger.info("Running null session on rank {}.".format(self.rank))
-                    return status.Status()
+                    return True
                 def close(self):
                     logger.info("Closing null session.")
                     # Workaround for bug gmxapi-214
-                    if not gmxapi.has_feature('0.0.7-bugfix-https://github.com/kassonlab/gmxapi/issues/214'):
-                        context._api_object = gmxapi.Context()
+                    if not _gmxapi.has_feature('0.0.7-bugfix-https://github.com/kassonlab/gmxapi/issues/214'):
+                        context._api_object = _gmxapi.Context()
                     return
 
             self._session = NullSession()
@@ -1099,11 +1018,6 @@ class Context(object):
         return False
 
 
-# The interface and functionality of ParallelArrayContext is the new generic
-# Context behavior, but we need to keep the old name for compatibility for
-# the moment.
-ParallelArrayContext = Context
-
 def get_context(work=None):
     """Get a concrete Context object.
 
@@ -1131,13 +1045,13 @@ def get_context(work=None):
     """
     # We need to define an interface for WorkSpec objects so that we don't need
     # to rely on typing and inter-module dependencies.
-    from gmx import workflow
+    from .workflow import WorkSpec
     workspec = None
     if work is not None:
-        if isinstance(work, workflow.WorkSpec):
+        if isinstance(work, WorkSpec):
             workspec = work
         elif hasattr(work, 'workspec') and isinstance(work.workspec,
-                                                      workflow.WorkSpec):
+                                                      WorkSpec):
             workspec = work.workspec
         else:
             raise exceptions.ValueError('work argument must provide a gmx.workflow.WorkSpec.')
diff --git a/python_packaging/src/gmxapi/simulation/mdrun.py b/python_packaging/src/gmxapi/simulation/mdrun.py
new file mode 100644 (file)
index 0000000..876ab83
--- /dev/null
@@ -0,0 +1,126 @@
+#
+# This file is part of the GROMACS molecular simulation package.
+#
+# Copyright (c) 2019, by the GROMACS development team, led by
+# Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
+# and including many others, as listed in the AUTHORS file in the
+# top-level source directory and at http://www.gromacs.org.
+#
+# GROMACS is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public License
+# as published by the Free Software Foundation; either version 2.1
+# of the License, or (at your option) any later version.
+#
+# GROMACS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with GROMACS; if not, see
+# http://www.gnu.org/licenses, or write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
+#
+# If you want to redistribute modifications to GROMACS, please
+# consider that scientific software is very special. Version
+# control is crucial - bugs must be traceable. We will be happy to
+# consider code for inclusion in the official distribution, but
+# derived work must not be called official GROMACS. Details are found
+# in the README & COPYING files - if they are missing, get the
+# official version at http://www.gromacs.org.
+#
+# To help us fund GROMACS development, we humbly ask that you cite
+# the research papers on the package. Check out http://www.gromacs.org.
+
+"""mdrun operation module
+
+The mdrun operation (in its first draft) conforms to the user-level API, but does
+not use the Python Context resource manager. It uses either the legacy 0.0.7
+Context or its own Context, also implemented in this module.
+"""
+
+import typing
+
+from gmxapi import exceptions
+from gmxapi.operation import AbstractOperation
+from gmxapi.operation import function_wrapper
+# The following imports are not marked as public API.
+# TODO: Resolve public API and exposure.
+from . import workflow
+
+
+def mdrun_dispatcher(context, *, input, label: str = None, **kwargs) -> typing.Type['AbstractOperation']:
+    """Dispatch to an appropriate director based on the context and input.
+
+    Runs appropriate director code to set up an operation, returning a handle to
+    a simulation operation node.
+
+    Arguments:
+        context: Execution context in which to attempt to instantiate an operation from input
+        input: operation input. Argument type is context dependent.
+        label: human readable tag for the work node to be created.
+
+    Additional key word arguments are passed on to the dispatched factory.
+
+    """
+    # TODO: This is a legacy import that should be updated.
+    from .context import get_context
+    if label is not None:
+        raise exceptions.NotImplementedError('sorry... no labels yet')
+    try:
+        legacy_context = get_context(work=input)
+    except Exception:
+        legacy_context = None
+
+    def run_session():
+        with legacy_context as session:
+            session.run()
+        return True
+
+    if context is not None and context is legacy_context:
+        helper = function_wrapper(output={'data': bool})(run_session)
+        return helper(**kwargs)
+    else:
+        raise exceptions.ValueError('Could not dispatch MD input {} with context {}'.format(input, legacy_context))
+
+
+def mdrun(input=None) -> typing.Type['AbstractOperation']:
+    """MD simulation operation.
+
+    Arguments:
+        input : valid simulation input
+
+    Returns:
+        runnable operation to perform the specified simulation
+
+    The returned object has a `run()` method to launch the simulation.
+    Otherwise, this operation does not yet support the gmxapi data flow model.
+
+    `input` may be a TPR file name.
+
+    Note:
+        New function names will be appearing to handle tasks that are separate
+
+        "simulate" is plausibly a dispatcher or base class for various tasks
+        dispatched by mdrun. Specific work factories are likely "minimize,"
+        "test_particle_insertion," "legacy_simulation" (do_md), or "simulation"
+        composition (which may be leap-frog, vv, and other algorithms)
+    """
+    # If input looks like classic filename input, use the gmxapi 0.0.7
+    # implementation.
+    from .context import get_context
+    try:
+        work = workflow.from_tpr(input)
+        assert work is not None
+        work_context = get_context(work)
+        assert work_context is not None
+    except exceptions.UsageError:
+        # Input was not gmxapi 0.0.7 input.
+        work = None
+        work_context = None
+
+    # TODO: Inspect input to determine context.
+    kwargs = {}
+    handle = mdrun_dispatcher(context=work_context, input=work, label=None, **kwargs)
+
+    return handle
index 0e504f28bd0bdfb387fda84a643c207393530c13..91e4a77fa6a39ba96081cc144bd8c2643fdced34 100644 (file)
+#
+# This file is part of the GROMACS molecular simulation package.
+#
+# Copyright (c) 2019, by the GROMACS development team, led by
+# Mark Abraham, David van der Spoel, Berk Hess, and Erik Lindahl,
+# and including many others, as listed in the AUTHORS file in the
+# top-level source directory and at http://www.gromacs.org.
+#
+# GROMACS is free software; you can redistribute it and/or
+# modify it under the terms of the GNU Lesser General Public License
+# as published by the Free Software Foundation; either version 2.1
+# of the License, or (at your option) any later version.
+#
+# GROMACS is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public
+# License along with GROMACS; if not, see
+# http://www.gnu.org/licenses, or write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA.
+#
+# If you want to redistribute modifications to GROMACS, please
+# consider that scientific software is very special. Version
+# control is crucial - bugs must be traceable. We will be happy to
+# consider code for inclusion in the official distribution, but
+# derived work must not be called official GROMACS. Details are found
+# in the README & COPYING files - if they are missing, get the
+# official version at http://www.gromacs.org.
+#
+# To help us fund GROMACS development, we humbly ask that you cite
+# the research papers on the package. Check out http://www.gromacs.org.
+#
+# This file is based on the Kasson Lab gmxapi project release 0.0.7.4.
+# https://github.com/kassonlab/gmxapi/blob/v0.0.7.4/src/gmx/workflow.py
+# # https://github.com/kassonlab/gmxapi/blob/v0.0.7.4/LICENSE
 """
 Provide workflow-level utilities and classes
 ============================================
 
-Single-sim example:
-
-.. code-block:: python
-
-    >>> md = gmx.workflow.from_tpr(filename)
-    >>> gmx.run(md)
-    >>>
-    >>> # The above is shorthand for
-    >>> md = gmx.workflow.from_tpr(filename)
-    >>> with gmx.get_context(md.workspec) as session:
-    ...    session.run()
-
-Array sim example:
-
-.. code-block:: python
-
-    >>> md = gmx.workflow.from_tpr([filename1, filename2])
-    >>> gmx.run(md)
-
-The representation of work and the way it is dispatched are areas of active
-development. See also https://github.com/kassonlab/gmxapi/milestone/3
+Supports the implementation of operations in the gmxapi.simulation module.
 """
 
-from __future__ import absolute_import
-from __future__ import division
-from __future__ import print_function
-from __future__ import unicode_literals
-
-__all__ = ['WorkSpec', 'WorkElement']
+__all__ = ['from_tpr', 'WorkSpec', 'WorkElement']
 
+import collections
 import warnings
 import weakref
+from typing import Text, Iterable, Set
 
-from gmx import exceptions
-from gmx import logging
-from gmx.util import to_string
-from gmx.util import to_utf8
+import gmxapi as gmx
+from gmxapi import exceptions
 
 # Module-level logger
-logger = logging.getLogger(__name__)
+logger = gmx.logger.getChild(__name__)
 logger.info('Importing gmx.workflow')
 
 # Work specification version string.
 workspec_version = "gmxapi_workspec_0_1"
 logger.info("Using schema version {}.".format(workspec_version))
 
-# module-level constant indicating a workflow implementing parallel array work.
-# ARRAY = 0
+
+def to_utf8(input) -> bytes:
+    """Return a utf8 encoded byte sequence of the Unicode ``input`` or its string representation.
+
+    Returns:
+         :py:bytes byte sequence.
+    """
+    if isinstance(input, str):
+        value = input.encode('utf-8')
+    elif isinstance(input, bytes):
+        value = input
+    else:
+        try:
+            string = str(input)
+            value = string.encode('utf-8')
+        except Exception as e:
+            raise exceptions.ValueError("Input cannot be interpreted as a UTF-8 compatible string.") from e
+    return value
+
+
+def to_string(input) -> str:
+    """Return a Unicode string representation of ``input``.
+
+    If ``input`` or its string representation is not already a Unicode object, attempt to decode as utf-8.
+
+    Returns a native string, decoding utf-8 encoded byte sequences if necessary.
+    """
+    if isinstance(input, str):
+        value = input
+    else:
+        try:
+            value = input.decode('utf-8')
+        except Exception:
+            try:
+                value = str(input)
+            except Exception as e:
+                raise exceptions.ValueError("Cannot find a string representation of input.") from e
+    return value
 
 
 class GmxMap(dict):
     """Utility/compatibility class to ensure consistent keys.
 
-    Allow subscripting to use native str or Python 2 Unicode objects.
     Internally, converts all keys to native str for the current interpreter.
     """
     def keys(self):
@@ -163,7 +210,7 @@ class WorkSpec(object):
         # not own the context.
         self.__context_weak_ref = weakref.ref(context)
 
-    def _chase_deps(self, source_set, name_list):
+    def __chase_deps(self, source_set: Set[str], name_list: Iterable[Text]):
         """Helper to recursively generate dependencies before dependents.
 
         Given a set of WorkElement objects and a list of element names, generate WorkElements for
@@ -172,13 +219,13 @@ class WorkSpec(object):
 
         For example, to sequence an entire work specification into a reasonable order for instantiation, use
 
-            >>> workspec._chase_deps(set(workspec.elements.keys()), list(workspec.elements.keys()))
+            >>> workspec.__chase_deps(set(workspec.elements.keys()), list(workspec.elements.keys()))
 
         Note: as a member function of WorkSpec, we have access to the full WorkSpec elements data at all
         times, giving us extra flexibility in implementation and arguments.
 
         Args:
-            sources: a (super)set of element names from the current work spec (will be consumed)
+            source_set: a (super)set of element names from the current work spec (will be consumed)
             name_list: subset of *sources* to be sequenced
 
         Returns:
@@ -188,8 +235,9 @@ class WorkSpec(object):
         they depend are all named in *source_list* and available in the current
         work spec.
 
-        Note: *source_set* is a reference to an object that is modified arbitrarily.
+        Warning: *source_set* is a reference to an object that is modified arbitrarily.
         The caller should not re-use the object after calling _chase_deps().
+        (Make a copy first, if needed.)
 
         TODO: Separate out DAG topology operations from here and Context.__enter__()
         Our needs are simple enough that we probably don't need an external dependency
@@ -197,13 +245,14 @@ class WorkSpec(object):
         """
         # Recursively (depth-first) generate a topologically valid serialized DAG from source_set.
         assert isinstance(source_set, set)
-        # Warning: This is not at all a rigorous check.
-        # It is hard to check whether this is string-like or list-like in both Py 2.7 and 3.x
-        if not isinstance(name_list, (list, tuple, set)):
-            raise exceptions.ValueError('Must disambiguate "name_list" by passing a list or tuple.')
+        if isinstance(name_list, (str, bytes)):
+            warnings.warn('name_list appears to be a single name. Disambiguate a string by passing a list or tuple.')
+        assert isinstance(name_list, collections.abc.Iterable)
+
         # Make a copy of name_list in case the input reference is being used elsewhere during
         # iteration, such as for source_set, which is modified during the loop.
         for name in tuple(name_list):
+            assert isinstance(name, str)
             if name in source_set:
                 source_set.remove(name)
                 element = WorkElement.deserialize(self.elements[name], name=name, workspec=self)
@@ -218,7 +267,7 @@ class WorkSpec(object):
                                 'Dependencies should be a string or sequence of strings. Got {}'.format(type(item)))
                         dependency_list = [item]
                     for dependency in dependency_list:
-                        for recursive_dep in self._chase_deps(source_set, (dependency,)):
+                        for recursive_dep in self.__chase_deps(source_set, (dependency,)):
                             yield recursive_dep
                 yield element
             else:
@@ -229,15 +278,15 @@ class WorkSpec(object):
 
     def __iter__(self):
         source_set = set(self.elements.keys())
-        for element in self._chase_deps(source_set, source_set):
+        for element in self.__chase_deps(source_set, source_set):
             yield element
 
     def __hash__(self):
         """Uniquely identify this work specification.
 
-        Allows the spec to be used as a dictionary key in Python. Note that this hash is possibly dependent on the Python
-        implementation. It is not part of the gmxapi specification and should not be used outside of a single invocation
-        of a script.
+        Allows the spec to be used as a dictionary key in Python. Note that this hash is possibly dependent on the
+        Python implementation. It is not part of the gmxapi specification and should not be used outside of a single
+        invocation of a script.
         """
         # Hash the serialized elements, concatenated as a single string. Note that the order of elements and their
         # contents is not guaranteed, but should be consistent within a script invocation.
@@ -260,45 +309,20 @@ class WorkSpec(object):
             if hasattr(element, "depends"):
                 for dependency in element.depends:
                     if not dependency in self.elements:
-                        raise exceptions.UsageError("Element dependencies must already be specified before an Element may be added.")
+                        raise exceptions.UsageError(
+                            "Element dependencies must already be specified before an Element may be added.")
             # Okay, it looks like we have an element we can add
             if hasattr(element, "workspec") and element.workspec is not None and element.workspec is not self:
-                raise exceptions.Error("Element must be removed from its current WorkSpec to be added to this WorkSpec, but element removal is not yet implemented.")
+                raise exceptions.Error(
+                    "Element must be removed from its current WorkSpec to be added to this WorkSpec, but element "
+                    "removal is not yet implemented.")
             self.elements[element.name] = element.serialize()
             element.workspec = self
         else:
-            raise exceptions.ValueError("Provided object does not appear to be compatible with gmx.workflow.WorkElement.")
+            raise exceptions.ValueError(
+                "Provided object does not appear to be compatible with gmx.workflow.WorkElement.")
         logger.info("Added element {} to workspec.".format(element.name))
 
-    # def remove_element(self, name):
-    #     """Remove named element from work specification.
-    #
-    #     Does not delete references to WorkElement objects, but WorkElement objects will be moved to a None WorkSpec."""
-    # To implement, WorkElement attributes should be reworked as properties that dynamically act on the
-    # workspec reference. Additionally, WorkSpec may have to keep weak references to WorkElements in order
-    # to reset the WorkElement.workspec strong reference.
-
-    # def add(self, spec):
-    #     """
-    #     Merge the provided spec into this one.
-    #
-    #     We can't easily replace references to ``spec`` with references to the WorkSpec we are merging into, but we can
-    #     steal the work elements out of ``spec`` and leave it empty. We could also set an ``alias`` attribute in it or
-    #     something, but that seems unnecessary. Alternatively, we can set the new and old spec to be equal, but we would
-    #     need an additional abstraction layer to keep them from diverging again. Since client code will retain references
-    #     to the elements in the work spec, we need to be clear about when we are duplicating a WorkSpec versus obtaining
-    #     different references to the same.
-    #
-    #     This is an implementation detail that can be unresolved and hidden for now. The high-level interface only
-    #     requires that client code can bind different workflow elements together in a sensible way and get expected
-    #     results.
-    #
-    #     :param spec: WorkSpec to be merged into this one.
-    #     :return:
-    #
-    #     To do: consider instead a gmx.workflow.merge(workspecA, workspecB) free function that returns a new WorkSpec.
-    #     """
-
     def serialize(self):
         """Serialize the work specification in a form suitable to pass to any Context implementation.
 
@@ -328,7 +352,7 @@ class WorkSpec(object):
                                }
         for name, element in [(e, json.loads(to_string(self.elements[e]))) for e in sorted(self.elements.keys())]:
             dict_representation['elements'][str(name)] = element
-        serialization = json.dumps(dict_representation, ensure_ascii=True, sort_keys=True, separators=(',',':'))
+        serialization = json.dumps(dict_representation, ensure_ascii=True, sort_keys=True, separators=(',', ':'))
         return serialization.encode('utf-8')
 
     @classmethod
@@ -340,7 +364,7 @@ class WorkSpec(object):
         ver_out = workspec.version
         if ver_in != ver_out:
             message = "Expected work spec version {}. Got work spec version {}.".format(ver_out, ver_in)
-            raise exceptions.CompatibilityError(message)
+            raise exceptions.ValueError(message)
         for element in dict_representation['elements']:
             workspec.elements[element] = dict_representation['elements'][element]
         return workspec
@@ -574,7 +598,7 @@ def from_tpr(input=None, **kwargs):
 
     Setting ``end_time`` redefines the end point of the simulation trajectory from what was provided in
     ``input``. It is equivalent to changing the number of steps requested in the MDP (or TPR) input, but
-    it time is provided as picoseconds instead of a number of time steps.
+    the time is provided as picoseconds instead of a number of time steps.
 
     .. deprecated:: 0.0.7
         If ``steps=N`` is provided and N is an integer
@@ -594,12 +618,15 @@ def from_tpr(input=None, **kwargs):
         grid (tuple): Domain decomposition grid divisions (nx, ny, nz). (-dd)
         max_hours (float): Terminate after 0.99 times this many hours if simulation is still running. (-maxh)
         pme_ranks (int): number of separate ranks to be used for PME electrostatics. (-npme)
-        pme_threads_per_rank (int): Number of OpenMP threads per PME rank. (-ntomp_pme)
+        threads_per_pme_rank (int): Number of OpenMP threads per PME rank. (-ntomp_pme)
         steps (int): Override input files and run for this many steps. (-nsteps; deprecated)
         threads (int): Total number of threads to start. (-nt)
         threads_per_rank (int): number of OpenMP threads to start per MPI rank. (-ntomp)
         tmpi (int): number of thread-MPI ranks to start. (-ntmpi)
 
+    ..  versionchanged:: 0.1
+        *pme_threads_per_rank* renamed to *threads_per_pme_rank*.
+
     Returns:
         simulation member of a gmx.workflow.WorkSpec object
 
@@ -654,8 +681,13 @@ def from_tpr(input=None, **kwargs):
             params['tmpi'] = int(kwargs[arg_key])
         elif arg_key == 'threads_per_rank' or arg_key == 'ntomp':
             params['threads_per_rank'] = int(kwargs[arg_key])
-        elif arg_key == 'pme_threads_per_rank' or arg_key == 'ntomp_pme':
-            params['pme_threads_per_rank'] = int(kwargs[arg_key])
+        elif arg_key == 'pme_threads_per_rank' or arg_key == 'threads_per_pme_rank' or arg_key == 'ntomp_pme':
+            # TODO: Remove this temporary accommodation.
+            assert not gmx.version.api_is_at_least(0, 2)
+            if arg_key == 'pme_threads_per_rank':
+                warnings.warn("Key word pme_threads_per_rank has been renamed to threads_per_pme_rank.",
+                              DeprecationWarning)
+            params['threads_per_pme_rank'] = int(kwargs[arg_key])
         elif arg_key == 'steps' or arg_key == 'nsteps':
             if kwargs[arg_key] is None:
                 # None means "don't override the input" which is indicated by a parameter value of -2 in GROMACS 2019
index eb568abde8ddc15bcaeab6b557a00cad1f73beef..a306e1756178b8390b5d9d3c68d48b3a222d2113 100644 (file)
@@ -55,10 +55,10 @@ setup(
 
     # TODO: (pending infrastructure and further discussion) Replace with CMake variables from GMXAPI version.
     version='0.1.0.dev3',
-    python_requires='>=3.4, <4',
+    python_requires='>=3.5, <4',
     setup_requires=['setuptools>=28'],
 
-    packages=['gmxapi'],
+    packages=['gmxapi', 'gmxapi.simulation'],
     cmake_args=['-DCMAKE_OSX_DEPLOYMENT_TARGET:STRING=10.9',
                 '-DCMAKE_OSX_ARCHITECTURES:STRING=x86_64'],
 
index e36b7a19eeae7c4ff2d45ddc34dc15d500be63f7..172c1efe9268fd9f9f2773f39d9707ce2082a84c 100644 (file)
@@ -46,26 +46,24 @@ import logging
 import os
 import pytest
 
+import gmxapi as gmx
+gmx.logger.setLevel(logging.WARNING)
+
 # Configure the `logging` module before and non-built-in packages start to use it.
-logging.getLogger().setLevel(logging.DEBUG)
+logging.getLogger().setLevel(logging.WARNING)
 # create console handler
 ch = logging.StreamHandler()
-ch.setLevel(logging.DEBUG)
+ch.setLevel(logging.WARNING)
 # create formatter and add it to the handler
 formatter = logging.Formatter('%(asctime)s:%(name)s:%(levelname)s: %(message)s')
 ch.setFormatter(formatter)
 # add the handlers to the logger
 logging.getLogger().addHandler(ch)
 
-from gmxapi import _gmxapi
-
-from pytesthelpers import withmpi_only
 
 @pytest.mark.usefixtures('cleandir')
 def test_run_from_tpr(spc_water_box):
     assert os.path.exists(spc_water_box)
-    filename = os.path.abspath(spc_water_box)
-    system = _gmxapi.from_tpr(filename)
-    context = _gmxapi.Context()
-    md = system.launch(context)
+
+    md = gmx.mdrun(spc_water_box)
     md.run()