asyncmd 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,197 @@
1
+ # This file is part of asyncmd.
2
+ #
3
+ # asyncmd is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # asyncmd is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with asyncmd. If not, see <https://www.gnu.org/licenses/>.
15
+ import os
16
+ import logging
17
+ import aiofiles.os
18
+
19
+ from ..trajectory.trajectory import Trajectory
20
+ from .mdconfig import MDP
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def nstout_from_mdp(mdp: MDP, traj_type: str = "TRR") -> int:
27
+ """
28
+ Get minimum number of steps between outputs for trajectories from MDP.
29
+
30
+ Parameters
31
+ ----------
32
+ mdp : MDP
33
+ Config object from which the output step should be read.
34
+ traj_type : str, optional
35
+ Trajectory format for which output step should be read, "XTC" or "TRR",
36
+ by default "TRR".
37
+
38
+ Returns
39
+ -------
40
+ int
41
+ Minimum number of steps between two writes.
42
+
43
+ Raises
44
+ ------
45
+ ValueError
46
+ Raised when an unknown trajectory format `traj_type` is given.
47
+ ValueError
48
+ Raised when the given MDP would result in no output for the given
49
+ trajectory format `traj_type`.
50
+ """
51
+ if traj_type.upper() == "TRR":
52
+ keys = ["nstxout", "nstvout", "nstfout"]
53
+ elif traj_type.upper() == "XTC":
54
+ keys = ["nstxout-compressed", "nstxtcout"]
55
+ else:
56
+ raise ValueError("traj_type must be one of 'TRR' or 'XTC'.")
57
+
58
+ vals = []
59
+ for k in keys:
60
+ try:
61
+ # need to check for 0 (== no output!) in case somone puts the
62
+ # defaults (or reads an mdout.mdp where gmx lists all the defaults)
63
+ v = mdp[k]
64
+ if v == 0:
65
+ v = float("inf")
66
+ vals += [v]
67
+ except KeyError:
68
+ # not set, defaults to 0, so we ignore it
69
+ pass
70
+
71
+ nstout = min(vals, default=None)
72
+ if (nstout is None) or (nstout == float("inf")):
73
+ raise ValueError(f"The MDP you passed results in no {traj_type} "
74
+ +"trajectory output.")
75
+ return nstout
76
+
77
+
78
+ async def get_all_traj_parts(folder: str, deffnm: str,
79
+ traj_type: str = "TRR") -> "list[Trajectory]":
80
+ """
81
+ Find and return a list of trajectory parts produced by a GmxEngine.
82
+
83
+ NOTE: This returns only the parts that exist in ascending order.
84
+
85
+ Parameters
86
+ ----------
87
+ folder : str
88
+ path to a folder to search for trajectory parts
89
+ deffnm : str
90
+ deffnm (prefix of filenames) used in the simulation
91
+ traj_type : str, optional
92
+ Trajectory file ending("XTC", "TRR", "TNG", ...), by default "TRR"
93
+
94
+ Returns
95
+ -------
96
+ list[Trajectory]
97
+ Ordered list of all trajectory parts with given deffnm and type.
98
+ """
99
+ ending = traj_type.lower()
100
+ traj_files = await get_all_file_parts(folder=folder, deffnm=deffnm,
101
+ file_ending=ending)
102
+ trajs = [Trajectory(trajectory_files=traj_file,
103
+ structure_file=os.path.join(folder, f"{deffnm}.tpr")
104
+ )
105
+ for traj_file in traj_files]
106
+ return trajs
107
+
108
+
109
+ async def get_all_file_parts(folder: str, deffnm: str, file_ending: str) -> "list[str]":
110
+ """
111
+ Find and return all files with given ending produced by GmxEngine.
112
+
113
+ NOTE: This returns only the parts that exist in ascending order.
114
+
115
+ Parameters
116
+ ----------
117
+ folder : str
118
+ Path to a folder to search for trajectory parts.
119
+ deffnm : str
120
+ deffnm (prefix of filenames) used in the simulation.
121
+ file_ending : str
122
+ File ending of the requested filetype (with or without preceeding ".").
123
+
124
+ Returns
125
+ -------
126
+ list[str]
127
+ Ordered list of filepaths for files with given ending.
128
+ """
129
+ def partnum_suffix(num):
130
+ # construct gromacs num part suffix from simulation_part
131
+ num_suffix = ".part{:04d}".format(num)
132
+ return num_suffix
133
+
134
+ if not file_ending.startswith("."):
135
+ file_ending = "." + file_ending
136
+ content = await aiofiles.os.listdir(folder)
137
+ filtered = [f for f in content
138
+ if (f.startswith(f"{deffnm}.part")
139
+ and f.endswith(file_ending)
140
+ and (len(f) == len(deffnm) + 9 + len(file_ending))
141
+ )
142
+ ]
143
+ partnums = [int(f[len(deffnm) + 5:len(deffnm) + 9]) # get the 4 number digits
144
+ for f in filtered]
145
+ partnums.sort()
146
+ parts = [os.path.join(folder, f"{deffnm}{partnum_suffix(num)}{file_ending}")
147
+ for num in partnums]
148
+ return parts
149
+
150
+
151
+ def ensure_mdp_options(mdp: MDP, genvel: str = "no", continuation: str = "yes") -> MDP:
152
+ """
153
+ Ensure that some commonly used mdp options have the given values.
154
+
155
+ NOTE: Modifies the `MDP` inplace and returns it.
156
+
157
+ Parameters
158
+ ----------
159
+ mdp : MDP
160
+ Config object for which values should be ensured.
161
+ genvel : str, optional
162
+ Value for genvel option ("yes" or "no"), by default "no".
163
+ continuation : str, optional
164
+ Value for continuation option ("yes" or "no"), by default "yes".
165
+
166
+ Returns
167
+ -------
168
+ MDP
169
+ Reference to input config object with values for options as given.
170
+ """
171
+ try:
172
+ # make sure we do not generate velocities with gromacs
173
+ genvel_test = mdp["gen-vel"]
174
+ except KeyError:
175
+ logger.info(f"Setting 'gen-vel = {genvel}' in mdp.")
176
+ mdp["gen-vel"] = genvel
177
+ else:
178
+ if genvel_test != genvel:
179
+ logger.warning(f"Setting 'gen-vel = {genvel}' in mdp "
180
+ + f"(was '{genvel_test}').")
181
+ mdp["gen-vel"] = genvel
182
+ try:
183
+ # TODO/FIXME: this could also be 'unconstrained-start'!
184
+ # however already the gmx v4.6.3 docs say
185
+ # "continuation: formerly know as 'unconstrained-start'"
186
+ # so I think we can ignore that for now?!
187
+ continuation_test = mdp["continuation"]
188
+ except KeyError:
189
+ logger.info(f"Setting 'continuation = {continuation}' in mdp.")
190
+ mdp["continuation"] = continuation
191
+ else:
192
+ if continuation_test != continuation:
193
+ logger.warning(f"Setting 'continuation = {continuation}' in mdp "
194
+ + f"(was '{continuation_test}').")
195
+ mdp["continuation"] = continuation
196
+
197
+ return mdp
asyncmd/mdconfig.py ADDED
@@ -0,0 +1,440 @@
1
+ # This file is part of asyncmd.
2
+ #
3
+ # asyncmd is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # asyncmd is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with asyncmd. If not, see <https://www.gnu.org/licenses/>.
15
+ import os
16
+ import abc
17
+ import typing
18
+ import shutil
19
+ import logging
20
+ import collections
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class FlagChangeList(collections.abc.MutableSequence):
27
+ """A list that knows if it has been changed after initializing."""
28
+
29
+ def __init__(self, data: list) -> None:
30
+ """
31
+ Initialize a `FlagChangeList`.
32
+
33
+ Parameters
34
+ ----------
35
+ data : list
36
+ The data this `FlagChangeList` will hold.
37
+
38
+ Raises
39
+ ------
40
+ TypeError
41
+ Raised when data is not a :class:`list`.
42
+ """
43
+ if not isinstance(data, list):
44
+ raise TypeError("FlagChangeList must be initialized with a list.")
45
+ self._data = data
46
+ self._changed = False
47
+
48
+ @property
49
+ def changed(self) -> bool:
50
+ """
51
+ Whether this `FlagChangeList` has been modified since creation.
52
+
53
+ Returns
54
+ -------
55
+ bool
56
+ """
57
+ return self._changed
58
+
59
+ def __repr__(self) -> str:
60
+ return self._data.__repr__()
61
+
62
+ def __getitem__(self, index: int) -> typing.Any:
63
+ return self._data.__getitem__(index)
64
+
65
+ def __len__(self) -> int:
66
+ return self._data.__len__()
67
+
68
+ def __setitem__(self, index: int, value) -> None:
69
+ self._data.__setitem__(index, value)
70
+ self._changed = True
71
+
72
+ def __delitem__(self, index: int) -> None:
73
+ self._data.__delitem__(index)
74
+ self._changed = True
75
+
76
+ def insert(self, index: int, value: typing.Any):
77
+ """
78
+ Insert `value` at position given by `index`.
79
+
80
+ Parameters
81
+ ----------
82
+ index : int
83
+ The index of the new value in the `FlagChangeList`.
84
+ value : typing.Any
85
+ The value to insert into this `FlagChangeList`.
86
+ """
87
+ self._data.insert(index, value)
88
+ self._changed = True
89
+
90
+
91
+ class TypedFlagChangeList(FlagChangeList):
92
+ """
93
+ A :class:`FlagChangeList` with an ensured type for individual list items.
94
+ """
95
+
96
+ def __init__(self, data: typing.Iterable, dtype) -> None:
97
+ """
98
+ Initialize a `TypedFlagChangeList`.
99
+
100
+ Parameters
101
+ ----------
102
+ data : Iterable
103
+ (Initial) data for this `TypedFlagChangeList`.
104
+ dtype : Callable datatype
105
+ The datatype for all entries in this `TypedFlagChangeList`. Will be
106
+ called on every value seperately and is expected to convert to the
107
+ desired datatype.
108
+ """
109
+ self._dtype = dtype # set first to use in _convert_type method
110
+ if getattr(data, '__len__', None) is None:
111
+ # convienience for singular options,
112
+ # if it has no len attribute we assume it is the only item
113
+ data = [data]
114
+ elif isinstance(data, str):
115
+ # strings have a length but we still do not want to split them into
116
+ # single letters, so just put a list around
117
+ data = [data]
118
+ typed_data = [self._convert_type(v, index=i)
119
+ for i, v in enumerate(data)]
120
+ super().__init__(data=typed_data)
121
+
122
+ def _convert_type(self, value, index=None):
123
+ # here we ignore index, but passing it should in principal make it
124
+ # possible to use different dtypes for different indices
125
+ return self._dtype(value)
126
+
127
+ def __setitem__(self, index: int, value) -> None:
128
+ typed_value = self._convert_type(value, index=index)
129
+ self._data.__setitem__(index, typed_value)
130
+ self._changed = True
131
+
132
+ def insert(self, index: int, value) -> None:
133
+ """
134
+ Insert `value` at position given by `index`.
135
+
136
+ Parameters
137
+ ----------
138
+ index : int
139
+ The index of the new value in the `TypedFlagChangeList`.
140
+ value : typing.Any
141
+ The value to insert into this `TypedFlagChangeList`.
142
+ """
143
+ typed_value = self._convert_type(value, index=index)
144
+ self._data.insert(index, typed_value)
145
+ self._changed = True
146
+
147
+
148
+ # NOTE: only to define the interface
149
+ class MDConfig(collections.abc.MutableMapping):
150
+ @abc.abstractmethod
151
+ def parse(self):
152
+ # should read original file and populate self with key, value pairs
153
+ raise NotImplementedError
154
+
155
+ @abc.abstractmethod
156
+ def write(self, outfile):
157
+ # write out current config stored in self to outfile
158
+ raise NotImplementedError
159
+
160
+
161
+ class LineBasedMDConfig(MDConfig):
162
+ # abstract base class for line based parsing and writing,
163
+ # subclasses must implement `_parse_line()` method and should set the
164
+ # appropriate separator characters for their line format
165
+ # We assume that every line/option can be parsed and written on its own!
166
+ # We assume the order of the options in the written file is not relevant!
167
+ # We represent every line/option with a key (str), list of values pair
168
+ # values can have a specific type (e.g. int or float) or default to str.
169
+ # NOTE: Initially written for gmx, but we already had e.g. namd in mind and
170
+ # tried to make this as general as possible
171
+
172
+ # these are the gmx mdp options but should be fairly general
173
+ # (i.e. work at least for namd?)
174
+ _KEY_VALUE_SEPARATOR = " = "
175
+ _INTER_VALUE_CHAR = " "
176
+ # NOTE on typing
177
+ # use these to specify config parameters that are of type int or float
178
+ # parsed lines with dict key matching will then be converted
179
+ # any lines not matching will be left in their default str type
180
+ _FLOAT_PARAMS = [] # can have multiple values per config option
181
+ _FLOAT_SINGLETON_PARAMS = [] # must have one value per config option
182
+ _INT_PARAMS = [] # multiple int per option
183
+ _INT_SINGLETON_PARAMS = [] # one int per option
184
+ _STR_SINGLETON_PARAMS = [] # strings with only one value per option
185
+ # NOTE on SPECIAL_PARAM_DISPATCH
186
+ # can be used to set custom type convert functions on a per parameter basis
187
+ # the key must match the key in the dict for in the parsed line,
188
+ # the value must be a function taking the corresponding (parsed) line and
189
+ # which must return a FlagChangeList or subclass thereof
190
+ # this function will also be called with the new list of value(s) when the
191
+ # option is changed, i.e. it must also be able to check and cast a list of
192
+ # new values into the expected FlagChangeList format
193
+ # [note that it is probably easiest to subclass TypedFlagChangeList and
194
+ # overwrite only the '_check_type()' method]
195
+ _SPECIAL_PARAM_DISPATCH = {}
196
+
197
+ def __init__(self, original_file: str) -> None:
198
+ """
199
+ Initialize a :class:`LineBasedMDConfig`.
200
+
201
+ Parameters
202
+ ----------
203
+ original_file : str
204
+ Path to original config file (absolute or relative).
205
+ """
206
+ self._config = {}
207
+ self._changed = False
208
+ self._type_dispatch = self._construct_type_dispatch()
209
+ # property to set/check file and parse to config dictionary all in one
210
+ self.original_file = original_file
211
+
212
+ def _construct_type_dispatch(self):
213
+ def convert_len1_list_or_singleton(val, dtype):
214
+ # helper func that accepts len1 lists
215
+ # (as expected from `_parse_line`)
216
+ # but that also accepts single values and converts them to given
217
+ # dtype (which is what we expect can/will happen when the users set
218
+ # singleton vals, i.e. "val" instead of ["val"]
219
+ if isinstance(val, str) or getattr(val, '__len__', None) is None:
220
+ return dtype(val)
221
+ else:
222
+ return dtype(val[0])
223
+
224
+ # construct type conversion dispatch
225
+ type_dispatch = collections.defaultdict(
226
+ # looks a bit strange, but the factory func
227
+ # is called to produce the default value, i.e.
228
+ # we need a func that returns our default func
229
+ lambda:
230
+ lambda l: TypedFlagChangeList(data=l,
231
+ dtype=str)
232
+ )
233
+ type_dispatch.update({param: lambda l: TypedFlagChangeList(
234
+ data=l,
235
+ dtype=float
236
+ )
237
+ for param in self._FLOAT_PARAMS})
238
+ type_dispatch.update({param: lambda v: convert_len1_list_or_singleton(
239
+ val=v,
240
+ dtype=float,
241
+ )
242
+ for param in self._FLOAT_SINGLETON_PARAMS})
243
+ type_dispatch.update({param: lambda l: TypedFlagChangeList(
244
+ data=l,
245
+ dtype=int,
246
+ )
247
+ for param in self._INT_PARAMS})
248
+ type_dispatch.update({param: lambda v: convert_len1_list_or_singleton(
249
+ val=v,
250
+ dtype=int,
251
+ )
252
+ for param in self._INT_SINGLETON_PARAMS})
253
+ type_dispatch.update({param: lambda v: convert_len1_list_or_singleton(
254
+ val=v,
255
+ dtype=str,
256
+ )
257
+ for param in self._STR_SINGLETON_PARAMS})
258
+ type_dispatch.update(self._SPECIAL_PARAM_DISPATCH)
259
+ return type_dispatch
260
+
261
+ def __getstate__(self) -> dict:
262
+ state = self.__dict__.copy()
263
+ state["_type_dispatch"] = None
264
+ return state
265
+
266
+ def __setstate__(self, state: dict) -> None:
267
+ self.__dict__.update(state)
268
+ self._type_dispatch = self._construct_type_dispatch()
269
+
270
+ @abc.abstractmethod
271
+ def _parse_line(self, line: str) -> dict:
272
+ """
273
+ Parse a line of the configuration file and return a :class:`dict`.
274
+
275
+ Parameters
276
+ ----------
277
+ line : str
278
+ A single line of the read-in configuration file
279
+
280
+ Returns
281
+ ------
282
+ parsed : dict
283
+ Dictionary with a single (key, list of value(s)) pair representing
284
+ the parsed line.
285
+ """
286
+ # NOTE: this is the only function needed to complete the class,
287
+ # the rest of this metaclass assumes the following for this func:
288
+ # it must parse a single line and return the key, list of value(s) pair
289
+ # as a dict with one item, e.g. {key: list of value(s)}
290
+ # if the line is parsed as comment the dict must be empty, e.g. {}
291
+ # if the option/key is present but without value the list must be empty
292
+ # e.g. {key: []}
293
+ raise NotImplementedError
294
+
295
+ def __getitem__(self, key):
296
+ return self._config[key]
297
+
298
+ def __setitem__(self, key, value) -> None:
299
+ typed_value = self._type_dispatch[key](value)
300
+ self._config[key] = typed_value
301
+ self._changed = True
302
+
303
+ def __delitem__(self, key) -> None:
304
+ self._config.__delitem__(key)
305
+ self._changed = True
306
+
307
+ def __iter__(self):
308
+ return self._config.__iter__()
309
+
310
+ def __len__(self) -> int:
311
+ return self._config.__len__()
312
+
313
+ def __repr__(self) -> str:
314
+ return str({"changed": self._changed,
315
+ "original_file": self.original_file,
316
+ "content": self._config.__repr__(),
317
+ }
318
+ )
319
+
320
+ def __str__(self) -> str:
321
+ repr_str = (f"{type(self)} has been changed since parsing: "
322
+ + f"{self._changed}\n"
323
+ )
324
+ repr_str += "Current content:\n"
325
+ repr_str += "----------------\n"
326
+ for key, val in self.items():
327
+ repr_str += f"{key} : {val}\n"
328
+ return repr_str
329
+
330
+ @property
331
+ def original_file(self) -> str:
332
+ """
333
+ Return the original config file this :class:`LineBasedMDConfig` parsed.
334
+
335
+ Returns
336
+ -------
337
+ str
338
+ Path to the original file.
339
+ """
340
+ return self._original_file
341
+
342
+ @original_file.setter
343
+ def original_file(self, value: str) -> None:
344
+ # NOTE: (re)setting the file also replaces the current config with
345
+ # what we parse from that file
346
+ value = os.path.relpath(value)
347
+ if not os.path.isfile(value):
348
+ raise ValueError(f"Can not access the file {value}")
349
+ self._original_file = value
350
+ self.parse()
351
+
352
+ @property
353
+ def changed(self) -> bool:
354
+ """
355
+ Indicate if the current configuration differs from original_file.
356
+
357
+ Returns
358
+ -------
359
+ bool
360
+ Whether we changed the configuration w.r.t. ``original_file``.
361
+ """
362
+ # NOTE: we default to False, i.e. we expect that anything that
363
+ # does not have a self.changed attribute is not a container
364
+ # and we (the dictionary) would know that it changed
365
+ return self._changed or any([getattr(v, "changed", False)
366
+ for v in self._config.values()]
367
+ )
368
+
369
+ def parse(self):
370
+ """Parse the current ``self.original_file`` to update own state."""
371
+ with open(self.original_file, "r") as f:
372
+ # NOTE: we split at newlines on all platforms by iterating over the
373
+ # file, i.e. python takes care of the differnt platforms and
374
+ # newline chars for us :)
375
+ parsed = {}
376
+ for line in f:
377
+ line_parsed = self._parse_line(line.rstrip("\n"))
378
+ # check for duplicate options, we warn but take the last one
379
+ for key in line_parsed:
380
+ try:
381
+ # check if we already have a value for that option
382
+ _ = parsed[key]
383
+ except KeyError:
384
+ # as it should be
385
+ pass
386
+ else:
387
+ # warn that we will only keep the last occurenc of key
388
+ logger.warning("Parsed duplicate configuration option "
389
+ + f"({key}). Last values encountered "
390
+ + "take precedence.")
391
+ parsed.update(line_parsed)
392
+ # convert the known types
393
+ self._config = {key: self._type_dispatch[key](value)
394
+ for key, value in parsed.items()}
395
+ self._changed = False
396
+
397
+ def write(self, outfile: str, overwrite: bool = False) -> None:
398
+ """
399
+ Write current configuration to outfile.
400
+
401
+ Parameters
402
+ ----------
403
+ outfile : str
404
+ Path to outfile (relative or absolute).
405
+ overwrite : bool, optional
406
+ If True overwrite existing files, by default False.
407
+
408
+ Raises
409
+ ------
410
+ ValueError
411
+ Raised when `overwrite=False` but `outfile` exists.
412
+ """
413
+ outfile = os.path.relpath(outfile)
414
+ if os.path.exists(outfile) and not overwrite:
415
+ raise ValueError(f"overwrite=False and file exists ({outfile}).")
416
+ if not self.changed:
417
+ # just copy the original
418
+ shutil.copy2(src=self.original_file, dst=outfile)
419
+ else:
420
+ # construct content for new file
421
+ lines = []
422
+ for key, value in self._config.items():
423
+ line = f"{key}{self._KEY_VALUE_SEPARATOR}"
424
+ try:
425
+ if len(value) >= 0:
426
+ if isinstance(value, str):
427
+ # it is a string singleton option
428
+ line += f"{value}"
429
+ else:
430
+ line += self._INTER_VALUE_CHAR.join(str(v)
431
+ for v in value
432
+ )
433
+ except TypeError:
434
+ # not a Sequence/Iterable or string,
435
+ # i.e. (probably) one of the float/int singleton options
436
+ line += f"{value}"
437
+ lines += [line]
438
+ # concatenate the lines and write out at once
439
+ with open(outfile, "w") as f:
440
+ f.write("\n".join(lines))