Skip to content

feature: IMDReader Integration #4923

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

Draft
wants to merge 5 commits into
base: develop
Choose a base branch
from
Draft
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
3 changes: 3 additions & 0 deletions .github/actions/setup-deps/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ inputs:
default: 'seaborn>=0.7.0'
tidynamics:
default: 'tidynamics>=1.0.0'
imdclient:
default: 'imdclient'
Comment on lines +85 to +86
Copy link
Member

Choose a reason for hiding this comment

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

It's going to take time to get imdclient to a stage where the imdclient package does not actually affect MDAnalysis.

Is there a way that we could temporarily (for initial CI testing) install imdclient from a branch or tarball, e.g., in a pip section? Then we could fairly rapidly create a preliminary (unpublished) imdclient package without IMDReader.

Copy link
Member

Choose a reason for hiding this comment

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

By initial CI testing, do you mean "in this PR"?

There's a pip section just below, which should work if you put in the git location for pip install, but also you can just temporarily modify the CI script to do an additional pip install if it's for "testing within the PR itself".

If it's "after merge", this would require more discussion.

Copy link
Member

Choose a reason for hiding this comment

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

Just for right now to bootstrap the PR.

I don't want to merge without a solid conda-forge imdclient package in place.

# pip-installed min dependencies
coverage:
default: 'coverage'
Expand Down Expand Up @@ -131,6 +133,7 @@ runs:
${{ inputs.distopia }}
${{ inputs.gsd }}
${{ inputs.h5py }}
${{ inputs.imdclient }}
${{ inputs.hole2 }}
${{ inputs.joblib }}
${{ inputs.netcdf4 }}
Expand Down
1 change: 1 addition & 0 deletions azure-pipelines.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ jobs:
displayName: 'Install tools'
- script: >-
python -m pip install --only-binary=scipy,h5py
imdclient
cython
hypothesis
h5py>=2.10
Expand Down
1 change: 1 addition & 0 deletions maintainer/conda/environment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies:
- sphinxcontrib-bibtex
- mdaencore
- waterdynamics
- imdclient
- pip:
- mdahole2
- pathsimanalysis
Expand Down
196 changes: 196 additions & 0 deletions package/MDAnalysis/coordinates/IMD.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""
IMDReader --- :mod:`MDAnalysis.coordinates.IMD`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Read and analyze simulation data interactively using `IMDClient`_.

.. _IMDClient: https://github.com/Becksteinlab/imdclient

Units
-----
The units in IMDv3 are fixed.

.. list-table::
:widths: 10 10
:header-rows: 1

* - Measurement
- Unit
* - Length
- angstrom
* - Velocity
- angstrom/picosecond
* - Force
- kilojoules/(mol*angstrom)
* - Time
- picosecond
* - Energy
- kilojoules/mol

Classes
-------

.. autoclass:: IMDReader
:members:
:inherited-members:

"""

import numpy as np
import logging

from MDAnalysis.coordinates import core
from MDAnalysis.lib.util import store_init_arguments
from MDAnalysis.coordinates.base import StreamReaderBase

try:
import imdclient
from imdclient.IMDClient import IMDClient

Check warning on line 48 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L48

Added line #L48 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

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

These need test coverage with mocks

Copy link
Author

Choose a reason for hiding this comment

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

Sorry @hmacdope, I'm not entirely sure what you mean here, could you please elaborate, thank you!

Copy link
Member

Choose a reason for hiding this comment

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

@hmacdope do you have a moment to add some pointers for mocks, e.g., examples in the current tests that you have in mind?

Copy link
Member

Choose a reason for hiding this comment

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

You can get coverage on the import guards by mocking the import using something like this. This was taken verbaitm from a PR of mine but the general idea should carry over nicely. Doesn't use mock directly but still effective.

def test_HAS_DISTOPIA_distopia_too_old():
    # mock a version of distopia that is too old
    sys.modules.pop("distopia", None)
    sys.modules.pop("MDAnalysis.lib._distopia", None)

    module_name = "distopia"
    mocked_module = ModuleType(module_name)
    # too old version
    mocked_module.__version__ = "0.1.0"
    sys.modules[module_name] = mocked_module

    import MDAnalysis.lib._distopia 
    assert HAS_DISTOPIA

except ImportError:
HAS_IMDCLIENT = False

# Allow building documentation without imdclient
import types

class MockIMDClient:
pass
imdclient = types.ModuleType("imdclient")
imdclient.IMDClient = MockIMDClient

else:
HAS_IMDCLIENT = True

Check warning on line 61 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L61

Added line #L61 was not covered by tests

logger = logging.getLogger("MDAnalysis.coordinates.IMDReader")


class IMDReader(StreamReaderBase):
Copy link
Member

@IAlibay IAlibay Feb 19, 2025

Choose a reason for hiding this comment

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

I think I'm getting confused about which PR is for which thing. @orbeckst given our discussion earlier this week, and your comment above which I take to be "IMDClient is still in flux", does it not make sense for the IMDReader to exist upstream and then just import it here? (edit: here my intent is "well then you could make releases and it wouldn't be limited to MDA release frequency").

Copy link
Member

Choose a reason for hiding this comment

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

We have to split IMDReader from imdclient and make a version of imdclient without IMDReader (which is in the works Becksteinlab/imdclient#54 ). At the same time we are moving what was split off into coordinates.IMD.

Amru is working on both at the moment.

The way IMDReader depends on imdclient is not the problem, and imdclient itself is also pretty stable, it's just that the tests for imdclient have made use of a lot of MDAnalysis/IMDReader for convenience, and we now have to rewrite some of these tests to use bare-bones python.

"""
Reader for IMD protocol packets.

Parameters
----------
filename : a string of the form "imd://host:port" where host is the hostname
or IP address of the listening GROMACS server and port
is the port number.
n_atoms : int (optional)
number of atoms in the system. defaults to number of atoms
in the topology. Don't set this unless you know what you're doing.
kwargs : dict (optional)
keyword arguments passed to the constructed :class:`IMDClient`
"""

format = "IMD"
one_pass = True

@store_init_arguments
def __init__(
self,
filename,
convert_units=True,
n_atoms=None,
**kwargs,
):
if not HAS_IMDCLIENT:
raise ImportError(

Check warning on line 94 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L94

Added line #L94 was not covered by tests
"IMDReader requires the imdclient package. "
"Please install it with 'pip install imdclient'."
)

super(IMDReader, self).__init__(filename, **kwargs)

Check warning on line 99 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L99

Added line #L99 was not covered by tests

self._imdclient = None
logger.debug("IMDReader initializing")

Check warning on line 102 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L101-L102

Added lines #L101 - L102 were not covered by tests

if n_atoms is None:
raise ValueError("IMDReader: n_atoms must be specified")
self.n_atoms = n_atoms

Check warning on line 106 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L105-L106

Added lines #L105 - L106 were not covered by tests

host, port = parse_host_port(filename)

Check warning on line 108 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L108

Added line #L108 was not covered by tests

# This starts the simulation
self._imdclient = IMDClient(host, port, n_atoms, **kwargs)

Check warning on line 111 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L111

Added line #L111 was not covered by tests

imdsinfo = self._imdclient.get_imdsessioninfo()

Check warning on line 113 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L113

Added line #L113 was not covered by tests
# NOTE: after testing phase, fail out on IMDv2

self.ts = self._Timestep(

Check warning on line 116 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L116

Added line #L116 was not covered by tests
self.n_atoms,
positions=imdsinfo.positions,
velocities=imdsinfo.velocities,
forces=imdsinfo.forces,
**self._ts_kwargs,
)

self._frame = -1

Check warning on line 124 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L124

Added line #L124 was not covered by tests

try:
self._read_next_timestep()
except StopIteration as e:
raise RuntimeError("IMDReader: No data found in stream") from e

Check warning on line 129 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L126-L129

Added lines #L126 - L129 were not covered by tests

def _read_frame(self, frame):

try:
imdf = self._imdclient.get_imdframe()
except EOFError as e:
raise e

Check warning on line 136 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L133-L136

Added lines #L133 - L136 were not covered by tests

self._frame = frame
self._load_imdframe_into_ts(imdf)

Check warning on line 139 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L138-L139

Added lines #L138 - L139 were not covered by tests

logger.debug("IMDReader: Loaded frame %d", self._frame)
return self.ts

Check warning on line 142 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L141-L142

Added lines #L141 - L142 were not covered by tests

def _load_imdframe_into_ts(self, imdf):
self.ts.frame = self._frame

Check warning on line 145 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L145

Added line #L145 was not covered by tests
if imdf.time is not None:
self.ts.time = imdf.time

Check warning on line 147 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L147

Added line #L147 was not covered by tests
# NOTE: timestep.pyx "dt" method is suspicious bc it uses "new" keyword for a float
self.ts.data["dt"] = imdf.dt
self.ts.data["step"] = imdf.step

Check warning on line 150 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L149-L150

Added lines #L149 - L150 were not covered by tests
if imdf.energies is not None:
self.ts.data.update(

Check warning on line 152 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L152

Added line #L152 was not covered by tests
{k: v for k, v in imdf.energies.items() if k != "step"}
)
if imdf.box is not None:
self.ts.dimensions = core.triclinic_box(*imdf.box)

Check warning on line 156 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L156

Added line #L156 was not covered by tests
if imdf.positions is not None:
# must call copy because reference is expected to reset
# see 'test_frame_collect_all_same' in MDAnalysisTests.coordinates.base
np.copyto(self.ts.positions, imdf.positions)

Check warning on line 160 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L160

Added line #L160 was not covered by tests
if imdf.velocities is not None:
np.copyto(self.ts.velocities, imdf.velocities)

Check warning on line 162 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L162

Added line #L162 was not covered by tests
if imdf.forces is not None:
np.copyto(self.ts.forces, imdf.forces)

Check warning on line 164 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L164

Added line #L164 was not covered by tests

@staticmethod
def _format_hint(thing):
try:
parse_host_port(thing)
except:
return False
return HAS_IMDCLIENT and True

Check warning on line 172 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L172

Added line #L172 was not covered by tests

def close(self):
"""Gracefully shut down the reader. Stops the producer thread."""
logger.debug("IMDReader close() called")

Check warning on line 176 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L176

Added line #L176 was not covered by tests
if self._imdclient is not None:
self._imdclient.stop()

Check warning on line 178 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L178

Added line #L178 was not covered by tests
# NOTE: removeme after testing
logger.debug("IMDReader shut down gracefully.")

Check warning on line 180 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L180

Added line #L180 was not covered by tests

# NOTE: think of other edge cases as well- should be robust
def parse_host_port(filename):
if not filename.startswith("imd://"):
raise ValueError("IMDReader: URL must be in the format 'imd://host:port'")
# Check if the format is correct
parts = filename.split("imd://")[1].split(":")

Check warning on line 187 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L187

Added line #L187 was not covered by tests
if len(parts) == 2:
host = parts[0]
try:
port = int(parts[1])
return (host, port)
except ValueError as e:
raise ValueError("IMDReader: Port must be an integer") from e

Check warning on line 194 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L189-L194

Added lines #L189 - L194 were not covered by tests
else:
raise ValueError("IMDReader: URL must be in the format 'imd://host:port'")

Check warning on line 196 in package/MDAnalysis/coordinates/IMD.py

View check run for this annotation

Codecov / codecov/patch

package/MDAnalysis/coordinates/IMD.py#L196

Added line #L196 was not covered by tests
6 changes: 6 additions & 0 deletions package/MDAnalysis/coordinates/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,11 @@ class can choose an appropriate reader automatically.
| library | | | file formats`_ and |
| | | | :mod:`MDAnalysis.coordinates.chemfiles` |
+---------------+-----------+-------+------------------------------------------------------+
| IMD | IP address| r/w | Receive simulation trajectory data using interactive |
| | and port | | molecular dynamics version 3 (IMDv3) by configuring |
| | number | | a socket address to a NAMD, GROMACS, or LAMMPS |
| | | | simulation. |
+---------------+-----------+-------+------------------------------------------------------+

.. [#a] This format can also be used to provide basic *topology*
information (i.e. the list of atoms); it is possible to create a
Expand Down Expand Up @@ -770,6 +775,7 @@ class can choose an appropriate reader automatically.
from . import DMS
from . import GMS
from . import GRO
from . import IMD
from . import INPCRD
from . import LAMMPS
from . import MOL2
Expand Down
Loading
Loading