openmm-opes 0.1.0__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,19 @@
1
+ """OpenMM implementation of On-the-fly Probability Enhanced Sampling (OPES)."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["OPES", "__version__"]
6
+
7
+
8
+ def __getattr__(name):
9
+ """Import OPES lazily (PEP 562).
10
+
11
+ An eager ``from .opes import OPES`` would also run on
12
+ ``import openmm_opes.kde``, dragging openmm in and breaking the guarantee
13
+ that kde and io stay OpenMM-free. Task 1's test enforces that.
14
+ """
15
+ if name == "OPES":
16
+ from .opes import OPES
17
+
18
+ return OPES
19
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
openmm_opes/io.py ADDED
@@ -0,0 +1,102 @@
1
+ """Multi-walker bias sharing through a shared directory.
2
+
3
+ Pure NumPy. This module must not import openmm, and deliberately knows
4
+ nothing about kernels: it moves flat dicts of arrays so it stays testable
5
+ without a simulation.
6
+
7
+ The format is ``np.savez``, not pickle. A walker reads files written by other
8
+ processes out of a shared directory, and ``pickle.load`` on data from another
9
+ process would be a remote-code-execution primitive. OpenMM's own Metadynamics
10
+ writes .npy for the same reason.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import warnings
18
+
19
+ import numpy as np
20
+
21
+ FILENAME_PATTERN = re.compile(r"kde_(\d+)_(\d+)\.npz")
22
+
23
+
24
+ class _LoadedBias:
25
+ """A peer's most recently seen state. Mirrors Metadynamics' own namedtuple."""
26
+
27
+ __slots__ = ("index", "state", "walkerId")
28
+
29
+ def __init__(self, walkerId: int, index: int, state: dict):
30
+ self.walkerId = walkerId
31
+ self.index = index
32
+ self.state = state
33
+
34
+
35
+ class BiasSharer:
36
+ """Reads and writes walker bias states in a shared directory.
37
+
38
+ Parameters
39
+ ----------
40
+ biasDir
41
+ Directory shared by every walker.
42
+ walkerId
43
+ This walker's identifier. Drawn at random when omitted, from a fresh
44
+ generator so the global NumPy random state is left undisturbed.
45
+ """
46
+
47
+ def __init__(self, biasDir: str, walkerId: int | None = None):
48
+ self.biasDir = biasDir
49
+ self.walkerId = (
50
+ int(np.random.default_rng().integers(0x7FFFFFFF))
51
+ if walkerId is None
52
+ else walkerId
53
+ )
54
+ self._saveIndex = 0
55
+ self._loaded: dict[int, _LoadedBias] = {}
56
+
57
+ def _path(self, prefix: str, index: int) -> str:
58
+ return os.path.join(self.biasDir, f"{prefix}_{self.walkerId}_{index}.npz")
59
+
60
+ def save(self, state: dict) -> None:
61
+ """Write this walker's state atomically and drop the previous index."""
62
+ oldName = self._path("kde", self._saveIndex)
63
+ self._saveIndex += 1
64
+ tempName = self._path("temp", self._saveIndex)
65
+ fileName = self._path("kde", self._saveIndex)
66
+ np.savez(tempName, **state)
67
+ os.rename(tempName, fileName)
68
+ if os.path.exists(oldName):
69
+ os.remove(oldName)
70
+
71
+ def load(self) -> dict[int, dict]:
72
+ """Return peer states that are new or have advanced since the last call."""
73
+ updated: dict[int, dict] = {}
74
+ for filename in os.listdir(self.biasDir):
75
+ match = FILENAME_PATTERN.match(filename)
76
+ if match is None:
77
+ continue
78
+ walkerId, index = int(match.group(1)), int(match.group(2))
79
+ if walkerId == self.walkerId:
80
+ continue
81
+ known = self._loaded.get(walkerId)
82
+ if known is not None and index <= known.index:
83
+ continue
84
+ try:
85
+ with np.load(
86
+ os.path.join(self.biasDir, filename), allow_pickle=False
87
+ ) as data:
88
+ state = {key: data[key] for key in data.files}
89
+ except (OSError, ValueError):
90
+ warnings.warn(
91
+ f"The file {filename} seems to have been deleted. Using the "
92
+ "latest loaded data from the same walker.",
93
+ stacklevel=2,
94
+ )
95
+ continue
96
+ self._loaded[walkerId] = _LoadedBias(walkerId, index, state)
97
+ updated[walkerId] = state
98
+ return updated
99
+
100
+ def getLoadedStates(self) -> dict[int, dict]:
101
+ """Every peer state seen so far, whether or not it changed recently."""
102
+ return {walkerId: bias.state for walkerId, bias in self._loaded.items()}
openmm_opes/kde.py ADDED
@@ -0,0 +1,450 @@
1
+ """Online kernel density estimation for OPES.
2
+
3
+ Pure NumPy/SciPy. This module must not import openmm.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import functools
9
+ from collections import namedtuple
10
+ from copy import copy
11
+
12
+ import numpy as np
13
+
14
+
15
+ class CVSpace:
16
+ """The domain of the collective variables: grid, periodicity, boundaries.
17
+
18
+ Parameters
19
+ ----------
20
+ variables
21
+ Sequence of collective variable descriptions. Each must expose
22
+ ``minValue``, ``maxValue``, ``gridWidth`` and ``periodic``; an
23
+ ``openmm.app.BiasVariable`` qualifies.
24
+ bounded
25
+ Whether non-periodic variables have reflective boundaries. When true the
26
+ internal grid is tripled with mirrored copies on each side, and
27
+ :meth:`foldedGrid` folds them back.
28
+ """
29
+
30
+ CV = namedtuple("CV", ["minValue", "maxValue", "gridWidth", "periodic"])
31
+
32
+ def __init__(self, variables, bounded: bool = False):
33
+ self.variables = [
34
+ self.CV(cv.minValue, cv.maxValue, cv.gridWidth, cv.periodic)
35
+ for cv in variables
36
+ ]
37
+ self.bounded = bounded
38
+ self._periodic = any(cv.periodic for cv in self.variables)
39
+ self._grid = []
40
+ for cv in self.variables:
41
+ a, b, n = cv.minValue, cv.maxValue, cv.gridWidth
42
+ points = np.linspace(a, b, n)
43
+ if bounded and not cv.periodic:
44
+ left = np.linspace(2 * a - b, a, n)
45
+ right = np.linspace(b, 2 * b - a, n)
46
+ left[-1] = right[0] = np.inf
47
+ points = np.concatenate((points, np.flip(left), np.flip(right)))
48
+ self._grid.append(points)
49
+ self._widths = np.array([cv.gridWidth for cv in self.variables])
50
+ self._lbounds = np.array([cv.minValue for cv in self.variables])
51
+ self._lengths = np.array([cv.maxValue for cv in self.variables]) - self._lbounds
52
+ if self._periodic:
53
+ self._pdims = tuple(i for i, cv in enumerate(self.variables) if cv.periodic)
54
+ self._plbounds = self._lbounds[list(self._pdims)]
55
+ self._plengths = self._lengths[list(self._pdims)]
56
+
57
+ @property
58
+ def gridShape(self) -> tuple[int, ...]:
59
+ """Shape of the CV-space grid, slowest axis first."""
60
+ return tuple(int(width) for width in reversed(self._widths))
61
+
62
+ @property
63
+ def numDimensions(self) -> int:
64
+ """Number of collective variables."""
65
+ return len(self.variables)
66
+
67
+ def displacement(self, position, endpoint):
68
+ """Displacement from ``position`` to ``endpoint``, minimum-image if periodic."""
69
+ disp = endpoint - position
70
+ if self._periodic:
71
+ disp[..., self._pdims] -= self._plengths * np.rint(
72
+ disp[..., self._pdims] / self._plengths
73
+ )
74
+ return disp
75
+
76
+ def endpoint(self, position, displacement):
77
+ """Endpoint reached from ``position``, wrapped into the periodic domain."""
78
+ end = position + displacement
79
+ if self._periodic:
80
+ end[..., self._pdims] = (
81
+ self._plbounds
82
+ + (end[..., self._pdims] - self._plbounds) % self._plengths
83
+ )
84
+ return end
85
+
86
+ def gridDistances(self, position):
87
+ """Per-axis distances from ``position`` to every grid node."""
88
+ distances = [
89
+ centers - x for centers, x in zip(self._grid, position, strict=True)
90
+ ]
91
+ if self._periodic:
92
+ for dim, length in zip(self._pdims, self._plengths, strict=True):
93
+ distances[dim] -= length * np.rint(distances[dim] / length)
94
+ # a periodic grid's last node is the same point as its first
95
+ distances[dim][-1] = distances[dim][0]
96
+ return distances
97
+
98
+ def closestNode(self, position) -> tuple[int, ...]:
99
+ """Index of the grid node nearest to ``position``, in gridShape order."""
100
+ indices = np.rint(
101
+ (self._widths - 1) * (position - self._lbounds) / self._lengths
102
+ ).astype(int)
103
+ if self._periodic:
104
+ indices[list(self._pdims)] %= self._widths[list(self._pdims)]
105
+ indices = np.clip(indices, 0, self._widths - 1)
106
+ return tuple(reversed(indices))
107
+
108
+ def foldedGrid(self, values):
109
+ """Fold a tripled bounded grid back onto the physical domain, in log space."""
110
+ if self.bounded:
111
+ for i, cv in enumerate(reversed(self.variables)):
112
+ if not cv.periodic:
113
+ values, left, right = np.array_split(values, 3, axis=i)
114
+ values = np.logaddexp(left, np.logaddexp(values, right))
115
+ return values
116
+
117
+
118
+ class KernelShape:
119
+ """A kernel profile: its per-dimension log normalization and its exponent."""
120
+
121
+ def __init__(self, name: str, logNorm: float, exponents):
122
+ self.name = name
123
+ self.logNorm = logNorm
124
+ self._exponents = exponents
125
+
126
+ def exponents(self, x):
127
+ """Log of the kernel profile at scaled distances ``x``."""
128
+ return self._exponents(x)
129
+
130
+
131
+ def _gaussianExponents(x):
132
+ return -0.5 * x**2
133
+
134
+
135
+ def _compactExponents(x):
136
+ values = 9 - x**2
137
+ mask = values > 0
138
+ result = np.empty_like(values)
139
+ result[mask] = 4 * np.log(values[mask])
140
+ result[~mask] = -np.inf
141
+ return result
142
+
143
+
144
+ #: Unbounded Gaussian profile; the default and the one both papers use.
145
+ GAUSSIAN = KernelShape("gaussian", np.log(2 * np.pi) / 2, _gaussianExponents)
146
+
147
+ #: Compact quartic profile with support of +/- 3 bandwidths. Its normalization
148
+ #: constant is exactly the integral of (9 - x**2)**4 over [-3, 3].
149
+ COMPACT = KernelShape("compact", np.log(559872 / 35), _compactExponents)
150
+
151
+ KERNEL_SHAPES = {shape.name: shape for shape in (GAUSSIAN, COMPACT)}
152
+
153
+
154
+ class Kernel:
155
+ """A multivariate kernel with diagonal covariance."""
156
+
157
+ def __init__(
158
+ self, cvSpace, position, bandwidth, logWeight, numSamples=1, shape=GAUSSIAN
159
+ ):
160
+ self.cvSpace = cvSpace
161
+ self.position = np.array(position, dtype=float)
162
+ self.bandwidth = np.array(bandwidth, dtype=float)
163
+ self.logWeight = logWeight
164
+ self.numSamples = numSamples
165
+ self.shape = shape
166
+ self.logHeight = self._computeLogHeight()
167
+
168
+ def __copy__(self):
169
+ return Kernel(
170
+ self.cvSpace,
171
+ self.position,
172
+ self.bandwidth,
173
+ self.logWeight,
174
+ self.numSamples,
175
+ self.shape,
176
+ )
177
+
178
+ def _computeLogHeight(self):
179
+ if np.any(self.bandwidth == 0):
180
+ return -np.inf
181
+ d = self.cvSpace.numDimensions
182
+ return self.logWeight - d * self.shape.logNorm - np.sum(np.log(self.bandwidth))
183
+
184
+ def _scaledDistances(self, points, bandwidths):
185
+ return self.cvSpace.displacement(self.position, points) / bandwidths
186
+
187
+ def findNearest(self, centers, bandwidths, ignore=()):
188
+ """Index of and squared Mahalanobis distance to the nearest center."""
189
+ if centers.size == 0:
190
+ return -1, np.inf
191
+ sqDistances = np.sum(self._scaledDistances(centers, bandwidths) ** 2, axis=-1)
192
+ if len(ignore):
193
+ sqDistances[list(ignore)] = np.inf
194
+ index = int(np.argmin(sqDistances))
195
+ return index, sqDistances[index]
196
+
197
+ def merge(self, other) -> None:
198
+ """Absorb ``other``, preserving total weight, mean and second moment."""
199
+ logSumWeights = np.logaddexp(self.logWeight, other.logWeight)
200
+ w1 = np.exp(self.logWeight - logSumWeights)
201
+ w2 = np.exp(other.logWeight - logSumWeights)
202
+ disp = self.cvSpace.displacement(self.position, other.position)
203
+ self.position = self.cvSpace.endpoint(self.position, w2 * disp)
204
+ self.bandwidth = np.sqrt(
205
+ w1 * self.bandwidth**2 + w2 * other.bandwidth**2 + w1 * w2 * disp**2
206
+ )
207
+ self.logWeight = logSumWeights
208
+ self.numSamples += other.numSamples
209
+ self.logHeight = self._computeLogHeight()
210
+
211
+ def evaluate(self, points):
212
+ """Log of the kernel at the given point or points."""
213
+ return self.logHeight + np.sum(
214
+ self.shape.exponents(self._scaledDistances(points, self.bandwidth)), axis=-1
215
+ )
216
+
217
+ def evaluateOnGrid(self):
218
+ """Log of the kernel on the CV-space grid, in gridShape order."""
219
+ distances = self.cvSpace.gridDistances(self.position)
220
+ exponents = [
221
+ self.shape.exponents(dist / sigma)
222
+ for dist, sigma in zip(distances, self.bandwidth, strict=True)
223
+ ]
224
+ return self.cvSpace.foldedGrid(
225
+ self.logHeight + functools.reduce(np.add.outer, reversed(exponents))
226
+ )
227
+
228
+
229
+ class OnlineKDE:
230
+ """Online kernel density estimate with on-the-fly kernel compression.
231
+
232
+ Parameters
233
+ ----------
234
+ cvSpace
235
+ The :class:`CVSpace` the estimate lives on.
236
+ compressionThreshold
237
+ Merge a new kernel into any existing kernel closer than this, measured
238
+ as a Mahalanobis distance. Zero disables merging.
239
+ useExistingBandwidths
240
+ Whether the nearest-neighbour search scales distances by the existing
241
+ kernels' bandwidths rather than the incoming kernel's.
242
+ kernelShape
243
+ Either ``"gaussian"`` or ``"compact"``.
244
+ """
245
+
246
+ def __init__(
247
+ self,
248
+ cvSpace,
249
+ compressionThreshold: float = 1.0,
250
+ useExistingBandwidths: bool = True,
251
+ kernelShape: str = "gaussian",
252
+ ):
253
+ if kernelShape not in KERNEL_SHAPES:
254
+ raise ValueError(
255
+ f"Unknown kernelShape {kernelShape!r}; "
256
+ f"expected one of {sorted(KERNEL_SHAPES)}"
257
+ )
258
+ self._cvSpace = cvSpace
259
+ self._compressionThreshold = compressionThreshold
260
+ self._useExistingBandwidths = useExistingBandwidths
261
+ self._shape = KERNEL_SHAPES[kernelShape]
262
+ self._kernels: list[Kernel] = []
263
+ self._logSumW = -np.inf
264
+ self._logSumWSq = -np.inf
265
+ self._logPK = np.empty(0)
266
+ self._logPG = np.full(cvSpace.gridShape, -np.inf)
267
+ self._d = cvSpace.numDimensions
268
+
269
+ def __bool__(self) -> bool:
270
+ return bool(self._kernels)
271
+
272
+ def __copy__(self):
273
+ new = self.__class__(
274
+ self._cvSpace,
275
+ self._compressionThreshold,
276
+ self._useExistingBandwidths,
277
+ self._shape.name,
278
+ )
279
+ new._kernels = list(map(copy, self._kernels))
280
+ new._logSumW = self._logSumW
281
+ new._logSumWSq = self._logSumWSq
282
+ new._logPK = self._logPK.copy()
283
+ new._logPG = self._logPG.copy()
284
+ return new
285
+
286
+ def __iadd__(self, other):
287
+ for kernel in other._kernels:
288
+ self._addKernel(
289
+ kernel.position,
290
+ kernel.bandwidth,
291
+ kernel.logWeight,
292
+ kernel.numSamples,
293
+ adjustBandwidth=False,
294
+ )
295
+ return self
296
+
297
+ @staticmethod
298
+ def _logsubexp(x, y):
299
+ """log(exp(x) - exp(y)), elementwise and numerically stable."""
300
+ result = np.full_like(x, -np.inf)
301
+ valid = y < x
302
+ inner = -np.exp(y[valid] - x[valid])
303
+ representable = inner > -1.0
304
+ inner[representable] = np.log1p(inner[representable])
305
+ inner[~representable] = -np.inf
306
+ result[valid] = inner + x[valid]
307
+ return result
308
+
309
+ def _removeKernels(self, centers, toRemove):
310
+ """Remove kernels by index, subtracting their contributions in log space.
311
+
312
+ Callers MUST add any replacement kernel's contribution to _logPK and
313
+ _logPG *before* calling this. _logsubexp loses precision as its two
314
+ arguments converge, and the replacement is what keeps the removed
315
+ kernel from dominating the density at its own center. See spec 12.4;
316
+ tests/test_kde.py pins the resulting margin.
317
+ """
318
+ toRemove = sorted(toRemove, reverse=True)
319
+ removed = []
320
+ for index in toRemove:
321
+ kernel = self._kernels.pop(index)
322
+ self._logPK = self._logsubexp(self._logPK, kernel.evaluate(centers))
323
+ self._logPG = self._logsubexp(self._logPG, kernel.evaluateOnGrid())
324
+ removed.append(kernel)
325
+ self._logPK = np.delete(self._logPK, toRemove)
326
+ return removed
327
+
328
+ def _pushKernel(self, newKernel):
329
+ centers = np.stack([k.position for k in self._kernels])
330
+ bandwidths = (
331
+ np.stack([k.bandwidth for k in self._kernels])
332
+ if self._useExistingBandwidths
333
+ else newKernel.bandwidth
334
+ )
335
+ threshold = self._compressionThreshold
336
+ index, minSqDist = newKernel.findNearest(centers, bandwidths)
337
+ toRemove = []
338
+ # threshold > 0 guard: without it, coincident kernels satisfy 0 <= 0
339
+ # and merge even when compression is meant to be disabled.
340
+ while threshold > 0 and index >= 0 and minSqDist <= threshold**2:
341
+ toRemove.append(index)
342
+ newKernel.merge(self._kernels[index])
343
+ index, minSqDist = newKernel.findNearest(centers, bandwidths, toRemove)
344
+ self._logPK = np.logaddexp(self._logPK, newKernel.evaluate(centers))
345
+ self._logPG = np.logaddexp(self._logPG, newKernel.evaluateOnGrid())
346
+ if toRemove:
347
+ self._removeKernels(centers, toRemove)
348
+ self._kernels.append(newKernel)
349
+ self._logPK = np.append(
350
+ self._logPK,
351
+ np.logaddexp.reduce(
352
+ [k.evaluate(newKernel.position) for k in self._kernels]
353
+ ),
354
+ )
355
+
356
+ def _addKernel(
357
+ self, position, bandwidth, logWeight, numSamples=1, adjustBandwidth=True
358
+ ):
359
+ self._logSumW = np.logaddexp(self._logSumW, logWeight)
360
+ self._logSumWSq = np.logaddexp(self._logSumWSq, 2 * logWeight)
361
+ if adjustBandwidth:
362
+ neff = np.exp(2 * self._logSumW - self._logSumWSq)
363
+ silverman = (neff * (self._d + 2) / 4) ** (-1 / (self._d + 4))
364
+ bandwidth = bandwidth * silverman
365
+ newKernel = Kernel(
366
+ self._cvSpace, position, bandwidth, logWeight, numSamples, self._shape
367
+ )
368
+ if self._kernels:
369
+ self._pushKernel(newKernel)
370
+ else:
371
+ self._kernels = [newKernel]
372
+ self._logPG = newKernel.evaluateOnGrid()
373
+ self._logPK = np.array([newKernel.logHeight])
374
+
375
+ def update(self, position, logWeight, variance) -> None:
376
+ """Deposit a kernel of the given log weight and per-CV variance."""
377
+ self._addKernel(position, np.sqrt(variance), logWeight)
378
+
379
+ def getNumKernels(self) -> int:
380
+ """Number of compressed kernels currently stored."""
381
+ return len(self._kernels)
382
+
383
+ def getLogPDF(self):
384
+ """Log of the normalized probability density on the grid."""
385
+ return self._logPG - self._logSumW
386
+
387
+ def getLogMeanDensity(self) -> float:
388
+ """Log of Z_n, the mean density over the compressed kernel centers."""
389
+ return (
390
+ np.logaddexp.reduce(self._logPK)
391
+ - np.log(len(self._kernels))
392
+ - self._logSumW
393
+ )
394
+
395
+ def evaluate(self, point) -> float:
396
+ """Log of the normalized density at a single point."""
397
+ return (
398
+ np.logaddexp.reduce([k.evaluate(point) for k in self._kernels])
399
+ - self._logSumW
400
+ )
401
+
402
+ def getState(self) -> dict:
403
+ """Flat, npz-writable snapshot of this estimate.
404
+
405
+ The CVSpace is deliberately excluded: all walkers share identical CV
406
+ definitions, so a reader constructs its own and calls setState.
407
+ """
408
+ d = self._cvSpace.numDimensions
409
+ if self._kernels:
410
+ positions = np.stack([k.position for k in self._kernels])
411
+ bandwidths = np.stack([k.bandwidth for k in self._kernels])
412
+ logWeights = np.array([k.logWeight for k in self._kernels])
413
+ numSamples = np.array([k.numSamples for k in self._kernels])
414
+ else:
415
+ positions = np.empty((0, d))
416
+ bandwidths = np.empty((0, d))
417
+ logWeights = np.empty(0)
418
+ numSamples = np.empty(0, dtype=int)
419
+ return {
420
+ "positions": positions,
421
+ "bandwidths": bandwidths,
422
+ "logWeights": logWeights,
423
+ "numSamples": numSamples,
424
+ "logSumW": float(self._logSumW),
425
+ "logSumWSq": float(self._logSumWSq),
426
+ "logPK": self._logPK.copy(),
427
+ }
428
+
429
+ def setState(self, state) -> None:
430
+ """Restore from a :meth:`getState` snapshot, discarding current contents."""
431
+ self._kernels = [
432
+ Kernel(self._cvSpace, position, bandwidth, logWeight, int(n), self._shape)
433
+ for position, bandwidth, logWeight, n in zip(
434
+ state["positions"],
435
+ state["bandwidths"],
436
+ state["logWeights"],
437
+ state["numSamples"],
438
+ strict=True,
439
+ )
440
+ ]
441
+ self._logSumW = float(state["logSumW"])
442
+ self._logSumWSq = float(state["logSumWSq"])
443
+ self._logPK = np.asarray(state["logPK"]).copy()
444
+ # Seeded with -inf so an empty kernel list restores cleanly; the source
445
+ # used a bare reduce here and raised TypeError on an empty sequence.
446
+ self._logPG = functools.reduce(
447
+ np.logaddexp,
448
+ (k.evaluateOnGrid() for k in self._kernels),
449
+ np.full(self._cvSpace.gridShape, -np.inf),
450
+ )
openmm_opes/opes.py ADDED
@@ -0,0 +1,465 @@
1
+ """The OPES bias, integrated with OpenMM as a CustomCVForce.
2
+
3
+ Closely mirrors ``openmm.app.Metadynamics``: same constructor shape, same
4
+ unit-coercion idiom, same force-group selection, same ``step`` contract.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import warnings
10
+ from copy import copy
11
+
12
+ import numpy as np
13
+ import openmm as mm
14
+ from openmm import unit
15
+
16
+ from .io import BiasSharer
17
+ from .kde import CVSpace, OnlineKDE
18
+
19
+
20
+ class RunningAverage:
21
+ """Accumulates a running mean of per-CV squared deviations."""
22
+
23
+ def __init__(self, numDimensions: int = 1):
24
+ self._num = 0
25
+ self._total = np.zeros(numDimensions)
26
+
27
+ def __iadd__(self, other):
28
+ self._total = self._total + other._total
29
+ self._num += other._num
30
+ return self
31
+
32
+ def copy(self):
33
+ """An independent copy. The array is copied, not aliased."""
34
+ new = self.__class__(self._total.shape[0])
35
+ new._num = self._num
36
+ # The source aliased this array, so merging peers into a copy silently
37
+ # corrupted the original. See spec 7.4.1.
38
+ new._total = self._total.copy()
39
+ return new
40
+
41
+ def update(self, sample) -> None:
42
+ """Fold one sample into the average."""
43
+ self._num += 1
44
+ self._total = self._total + sample
45
+
46
+ def get(self):
47
+ """The running average."""
48
+ return self._total / max(1, self._num)
49
+
50
+ def getState(self) -> dict:
51
+ """Flat, npz-writable snapshot."""
52
+ return {"num": float(self._num), "total": self._total.copy()}
53
+
54
+ def setState(self, state) -> None:
55
+ """Restore from a :meth:`getState` snapshot."""
56
+ self._num = int(state["num"])
57
+ self._total = np.asarray(state["total"]).copy()
58
+
59
+
60
+ class OPES:
61
+ """On-the-fly Probability Enhanced Sampling.
62
+
63
+ Implements OPES and its exploratory variant, following Invernizzi and
64
+ Parrinello, https://doi.org/10.1021/acs.jpclett.0c00497 and
65
+ https://doi.org/10.1021/acs.jctc.2c00152.
66
+
67
+ Parameters
68
+ ----------
69
+ system: System
70
+ The System to simulate. A CustomCVForce implementing the bias is
71
+ created and added to it.
72
+ variables: list of BiasVariable
73
+ The collective variables whose sampling should be enhanced.
74
+ temperature: temperature
75
+ The temperature at which the simulation is run.
76
+ barrier: energy
77
+ The free energy barrier the simulation should overcome.
78
+ frequency: int
79
+ Interval in time steps at which to deposit a kernel.
80
+ varianceFrequency: int or None
81
+ Interval in time steps at which to update the CV variance. When None,
82
+ the bandwidth is fixed and taken from each variable's ``biasWidth``,
83
+ which is interpreted as the standard deviation of the UNBIASED
84
+ distribution, exactly as both papers define sigma^(0).
85
+ biasFactor: float, optional
86
+ Defaults to ``barrier / kT``.
87
+ exploreMode: bool
88
+ Whether to apply the OPES-explore variant.
89
+ bounded: bool
90
+ Whether non-periodic CVs have reflective boundaries.
91
+ saveFrequency: int, optional
92
+ Interval in time steps at which to share the bias on disk. Must be a
93
+ multiple of ``frequency``.
94
+ biasDir: str, optional
95
+ Directory shared with other walkers.
96
+ warmupSteps: int, optional
97
+ When given, run this many steps without depositing kernels while
98
+ measuring the CV variance, then freeze it. See the spec, section 7.7.
99
+ compressionThreshold: float
100
+ Mahalanobis distance below which kernels are merged.
101
+ useExistingBandwidths: bool
102
+ Whether the merge search scales by existing kernels' bandwidths.
103
+ kernelShape: str
104
+ ``"gaussian"`` or ``"compact"``.
105
+ statsWindowSize: int
106
+ Window, in deposition strides, of the running CV-mean estimate.
107
+ """
108
+
109
+ def __init__(
110
+ self,
111
+ system,
112
+ variables,
113
+ temperature,
114
+ barrier,
115
+ frequency,
116
+ varianceFrequency,
117
+ biasFactor=None,
118
+ exploreMode: bool = False,
119
+ bounded: bool = False,
120
+ saveFrequency=None,
121
+ biasDir=None,
122
+ warmupSteps=None,
123
+ compressionThreshold: float = 1.0,
124
+ useExistingBandwidths: bool = True,
125
+ kernelShape: str = "gaussian",
126
+ statsWindowSize: int = 10,
127
+ ):
128
+ if not unit.is_quantity(temperature):
129
+ temperature = temperature * unit.kelvin
130
+ if not unit.is_quantity(barrier):
131
+ barrier = barrier * unit.kilojoules_per_mole
132
+
133
+ self.variables = variables
134
+ self.temperature = temperature
135
+ self.barrier = barrier
136
+ self.frequency = frequency
137
+ self.varianceFrequency = varianceFrequency
138
+ self.exploreMode = exploreMode
139
+ self.bounded = bounded
140
+ self.saveFrequency = saveFrequency
141
+ self.biasDir = biasDir
142
+ self.warmupSteps = warmupSteps
143
+ self.statsWindowSize = statsWindowSize
144
+
145
+ d = len(variables)
146
+ kbt = unit.MOLAR_GAS_CONSTANT_R * temperature
147
+ userSuppliedBiasFactor = biasFactor is not None
148
+ if not userSuppliedBiasFactor:
149
+ biasFactor = barrier / kbt
150
+ self.biasFactor = biasFactor
151
+ numPeriodics = sum(v.periodic for v in variables)
152
+ freeGroups = set(range(32)) - {f.getForceGroup() for f in system.getForces()}
153
+ self._validate(d, biasFactor, numPeriodics, freeGroups, userSuppliedBiasFactor)
154
+
155
+ prefactor = (1 - 1 / biasFactor) * kbt
156
+ if exploreMode:
157
+ prefactor *= biasFactor
158
+ self._kbt = kbt.in_units_of(unit.kilojoules_per_mole)
159
+ self._biasFactor = biasFactor
160
+ self._prefactor = prefactor
161
+ self._logEpsilon = -barrier / prefactor
162
+
163
+ self._cvSpace = CVSpace(variables, bounded)
164
+ self._cases = ("total",) + ("self",) * bool(saveFrequency)
165
+ kdeOptions = {
166
+ "compressionThreshold": compressionThreshold,
167
+ "useExistingBandwidths": useExistingBandwidths,
168
+ "kernelShape": kernelShape,
169
+ }
170
+ self._kde = {}
171
+ for case in self._cases:
172
+ self._kde[case] = OnlineKDE(self._cvSpace, **kdeOptions)
173
+ self._kde[f"{case}.rw"] = OnlineKDE(self._cvSpace, **kdeOptions)
174
+
175
+ self._adaptiveVariance = varianceFrequency is not None
176
+ self._interval = varianceFrequency or frequency
177
+ self._variance = {case: RunningAverage(d) for case in self._cases}
178
+ self._warmupComplete = warmupSteps is None
179
+ if self._adaptiveVariance:
180
+ self._tau = statsWindowSize * frequency // varianceFrequency
181
+ self._counter = 0
182
+ self._sampleMean = np.zeros(d)
183
+ else:
184
+ # Spec 7.5: biasWidth is the unbiased sigma^(0), while _variance
185
+ # holds a sampled-distribution variance, which is gamma times wider.
186
+ self._setFixedVariance(
187
+ biasFactor * np.array([v.biasWidth**2 for v in variables])
188
+ )
189
+
190
+ self._sharer = BiasSharer(biasDir) if biasDir is not None else None
191
+
192
+ gridWidths = [v.gridWidth for v in variables]
193
+ self._widths = [] if d == 1 else gridWidths
194
+ self._limits = [limit for v in variables for limit in (v.minValue, v.maxValue)]
195
+ periodic = numPeriodics == d
196
+ initial = np.full(int(np.prod(gridWidths)), -barrier / unit.kilojoules_per_mole)
197
+
198
+ energyFunction = "table(" + ",".join(f"cv{i}" for i in range(d)) + ")"
199
+ self._force = mm.CustomCVForce(energyFunction)
200
+ for i, variable in enumerate(variables):
201
+ self._force.addCollectiveVariable(f"cv{i}", variable.force)
202
+ # Explicit branch rather than a dynamic getattr, so ty can check it.
203
+ if d == 1:
204
+ table = mm.Continuous1DFunction(initial, *self._limits, periodic)
205
+ elif d == 2:
206
+ table = mm.Continuous2DFunction(
207
+ *self._widths, initial, *self._limits, periodic
208
+ )
209
+ else:
210
+ table = mm.Continuous3DFunction(
211
+ *self._widths, initial, *self._limits, periodic
212
+ )
213
+ self._force.addTabulatedFunction("table", table)
214
+ self._force.setForceGroup(max(freeGroups))
215
+ system.addForce(self._force)
216
+
217
+ def _validate(
218
+ self, d, biasFactor, numPeriodics, freeGroups, userSuppliedBiasFactor
219
+ ):
220
+ if self.varianceFrequency is not None and self.varianceFrequency <= 0:
221
+ raise ValueError("varianceFrequency must be positive or None")
222
+ if self.varianceFrequency and self.frequency % self.varianceFrequency != 0:
223
+ raise ValueError("varianceFrequency must be a divisor of frequency")
224
+ if (self.saveFrequency is None) != (self.biasDir is None):
225
+ raise ValueError("Must specify both saveFrequency and biasDir")
226
+ if self.saveFrequency is not None:
227
+ if self.saveFrequency <= 0:
228
+ raise ValueError("saveFrequency must be positive")
229
+ if self.saveFrequency % self.frequency != 0:
230
+ raise ValueError("saveFrequency must be a multiple of frequency")
231
+ if self.warmupSteps is not None:
232
+ if self.varianceFrequency is None:
233
+ raise ValueError("warmupSteps requires varianceFrequency")
234
+ if self.warmupSteps <= 0:
235
+ raise ValueError("warmupSteps must be positive")
236
+ if self.warmupSteps % self.varianceFrequency != 0:
237
+ raise ValueError("warmupSteps must be a multiple of varianceFrequency")
238
+ if self.warmupSteps // self.varianceFrequency < 2:
239
+ raise ValueError(
240
+ "warmupSteps must span at least two varianceFrequency intervals"
241
+ )
242
+ if biasFactor <= 1.0:
243
+ raise ValueError(
244
+ "biasFactor must be greater than 1"
245
+ if userSuppliedBiasFactor
246
+ else "barrier must be greater than 1 kT"
247
+ )
248
+ if numPeriodics not in [0, d]:
249
+ raise ValueError("OPES cannot handle mixed periodic/non-periodic variables")
250
+ if not 1 <= d <= 3:
251
+ raise ValueError("OPES requires 1, 2, or 3 collective variables")
252
+ if not freeGroups:
253
+ raise RuntimeError("OPES requires a free force group, but all are in use.")
254
+
255
+ def _setFixedVariance(self, value) -> None:
256
+ """Replace every accumulator with a frozen value (spec 7.5 and 7.7)."""
257
+ for case in self._cases:
258
+ average = RunningAverage(len(self.variables))
259
+ average.update(np.asarray(value, dtype=float))
260
+ self._variance[case] = average
261
+ self._adaptiveVariance = False
262
+
263
+ def getVariance(self):
264
+ """Variance currently used to size newly deposited kernels."""
265
+ return self._variance["total"].get()
266
+
267
+ def getNumKernels(self) -> int:
268
+ """Number of kernels in the estimate that defines the bias."""
269
+ return self._kde["total" if self.exploreMode else "total.rw"].getNumKernels()
270
+
271
+ def getBias(self):
272
+ """The OPES bias potential on the grid."""
273
+ kde = self._kde["total" if self.exploreMode else "total.rw"]
274
+ return self._prefactor * np.logaddexp(
275
+ kde.getLogPDF() - kde.getLogMeanDensity(), self._logEpsilon
276
+ )
277
+
278
+ def getFreeEnergy(self):
279
+ """Free energy as a function of the collective variables.
280
+
281
+ Returned as an N-dimensional array in kJ/mole. The i'th position along
282
+ an axis corresponds to ``minValue + i*(maxValue-minValue)/gridWidth``.
283
+ Always the importance-sampling estimate, which converges better than
284
+ the direct one in explore mode.
285
+ """
286
+ return -self._kbt * self._kde["total.rw"].getLogPDF()
287
+
288
+ def getAverageDensity(self):
289
+ """Z_n, the mean density over the explored CV space."""
290
+ kde = self._kde["total" if self.exploreMode else "total.rw"]
291
+ return np.exp(kde.getLogMeanDensity())
292
+
293
+ def getCollectiveVariables(self, simulation):
294
+ """Current values of all collective variables in a Simulation."""
295
+ return self._force.getCollectiveVariableValues(simulation.context)
296
+
297
+ def updateContext(self, context) -> None:
298
+ """Push the current bias into a Context."""
299
+ bias = self.getBias().value_in_unit(unit.kilojoules_per_mole)
300
+ self._force.getTabulatedFunction(0).setFunctionParameters(
301
+ *self._widths, bias.ravel(), *self._limits
302
+ )
303
+ self._force.updateParametersInContext(context)
304
+
305
+ def addKernel(self, values, biasEnergy, variance=None) -> None:
306
+ """Deposit a kernel into the probability estimates.
307
+
308
+ This does not refresh any Context; call :meth:`updateContext`
309
+ afterwards if a simulation is running.
310
+ """
311
+ if not unit.is_quantity(biasEnergy):
312
+ biasEnergy = biasEnergy * unit.kilojoules_per_mole
313
+ if variance is None:
314
+ variance = self._variance["total"].get()
315
+ if np.any(np.asarray(variance) <= 0):
316
+ # Spec 7.4.4: a zero bandwidth poisons the estimate with NaN.
317
+ warnings.warn(
318
+ "Skipping kernel deposition: the CV variance estimate is not "
319
+ "yet positive. Use a varianceFrequency smaller than frequency, "
320
+ "or set warmupSteps.",
321
+ stacklevel=2,
322
+ )
323
+ return
324
+ for case in self._cases:
325
+ self._kde[case].update(values, 0.0, variance)
326
+ self._kde[f"{case}.rw"].update(
327
+ values, biasEnergy / self._kbt, variance / self._biasFactor
328
+ )
329
+
330
+ def _updateSampleStats(self, values) -> None:
331
+ self._counter += 1
332
+ delta = self._cvSpace.displacement(self._sampleMean, values)
333
+ x = 1 / min(self._tau, self._counter)
334
+ self._sampleMean = self._cvSpace.endpoint(self._sampleMean, x * delta)
335
+ sqdev = delta * self._cvSpace.displacement(self._sampleMean, values)
336
+ for case in self._cases:
337
+ self._variance[case].update(sqdev)
338
+
339
+ def _syncWithDisk(self) -> None:
340
+ # Only ever called when saveFrequency is set, which _validate ties to
341
+ # biasDir being set, which is what constructs self._sharer.
342
+ assert self._sharer is not None
343
+ self._sharer.save(self._getSharedState())
344
+ if not self._sharer.load():
345
+ return
346
+ self._kde["total"] = copy(self._kde["self"])
347
+ self._kde["total.rw"] = copy(self._kde["self.rw"])
348
+ self._variance["total"] = self._variance["self"].copy()
349
+ for state in self._sharer.getLoadedStates().values():
350
+ self._kde["total"] += self._kdeFromState(state, "kde")
351
+ self._kde["total.rw"] += self._kdeFromState(state, "kdeRW")
352
+ peer = RunningAverage(len(self.variables))
353
+ peer.setState({"num": state["var_num"], "total": state["var_total"]})
354
+ self._variance["total"] += peer
355
+
356
+ def _getSharedState(self) -> dict:
357
+ state = {}
358
+ for prefix, key in (("kde", "self"), ("kdeRW", "self.rw")):
359
+ for name, value in self._kde[key].getState().items():
360
+ state[f"{prefix}_{name}"] = value
361
+ variance = self._variance["self"].getState()
362
+ state["var_num"] = variance["num"]
363
+ state["var_total"] = variance["total"]
364
+ return state
365
+
366
+ def _kdeFromState(self, state, prefix):
367
+ kde = OnlineKDE(self._cvSpace)
368
+ strip = len(prefix) + 1
369
+ kde.setState(
370
+ {
371
+ key[strip:]: value
372
+ for key, value in state.items()
373
+ if key.startswith(f"{prefix}_")
374
+ }
375
+ )
376
+ return kde
377
+
378
+ def step(self, simulation, steps) -> None:
379
+ """Advance the simulation by a number of time steps.
380
+
381
+ Parameters
382
+ ----------
383
+ simulation: Simulation
384
+ The Simulation to advance.
385
+ steps: int
386
+ The number of time steps to integrate.
387
+ """
388
+ stepsToGo = steps
389
+ while stepsToGo > 0:
390
+ nextSteps = min(
391
+ stepsToGo,
392
+ self._interval - simulation.currentStep % self._interval,
393
+ )
394
+ simulation.step(nextSteps)
395
+ if simulation.currentStep % self._interval == 0:
396
+ self._onInterval(simulation)
397
+ stepsToGo -= nextSteps
398
+
399
+ def _onInterval(self, simulation) -> None:
400
+ position = self.getCollectiveVariables(simulation)
401
+ if not self._warmupComplete:
402
+ self._updateSampleStats(position)
403
+ if simulation.currentStep >= self.warmupSteps:
404
+ self._finishWarmup()
405
+ return
406
+ if self._adaptiveVariance:
407
+ self._updateSampleStats(position)
408
+ if simulation.currentStep % self.frequency == 0:
409
+ groups = {self._force.getForceGroup()}
410
+ energy = simulation.context.getState(
411
+ getEnergy=True, groups=groups
412
+ ).getPotentialEnergy()
413
+ self.addKernel(position, energy)
414
+ self.updateContext(simulation.context)
415
+ if (
416
+ self.saveFrequency is not None
417
+ and simulation.currentStep % self.saveFrequency == 0
418
+ ):
419
+ self._syncWithDisk()
420
+
421
+ def _finishWarmup(self) -> None:
422
+ """Freeze sigma^(0) measured from the unbiased warm-up segment.
423
+
424
+ Nothing was deposited during warm-up, so the trajectory was unbiased
425
+ and this measurement is the unbiased variance. _variance holds a
426
+ sampled-distribution variance by convention, which is gamma times
427
+ wider, so that is what gets stored. This is the same transformation
428
+ the fixed-bandwidth path applies to biasWidth. See spec 7.5 and 7.7.
429
+ """
430
+ measured = self._variance["total"].get()
431
+ if np.any(measured <= 0):
432
+ raise ValueError(
433
+ "Warm-up produced a non-positive CV variance. Increase "
434
+ "warmupSteps or decrease varianceFrequency."
435
+ )
436
+ self._setFixedVariance(self._biasFactor * measured)
437
+ self._warmupComplete = True
438
+
439
+ def getState(self) -> dict:
440
+ """Flat, npz-writable snapshot of this sampler's accumulated state."""
441
+ state = self._getSharedState() if "self" in self._cases else {}
442
+ for prefix, key in (("total", "total"), ("totalRW", "total.rw")):
443
+ for name, value in self._kde[key].getState().items():
444
+ state[f"{prefix}_{name}"] = value
445
+ variance = self._variance["total"].getState()
446
+ state["totalVar_num"] = variance["num"]
447
+ state["totalVar_total"] = variance["total"]
448
+ # Persisted explicitly rather than re-derived from the step counter:
449
+ # re-deriving would risk rescaling an already-frozen variance by gamma
450
+ # a second time on reload. See spec 7.7.
451
+ state["warmupComplete"] = float(self._warmupComplete)
452
+ return state
453
+
454
+ def setState(self, state) -> None:
455
+ """Restore from a :meth:`getState` snapshot."""
456
+ for prefix, key in (("total", "total"), ("totalRW", "total.rw")):
457
+ self._kde[key] = self._kdeFromState(state, prefix)
458
+ average = RunningAverage(len(self.variables))
459
+ average.setState(
460
+ {"num": state["totalVar_num"], "total": state["totalVar_total"]}
461
+ )
462
+ self._variance["total"] = average
463
+ self._warmupComplete = bool(float(state["warmupComplete"]))
464
+ if self._warmupComplete and self.warmupSteps is not None:
465
+ self._adaptiveVariance = False
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.4
2
+ Name: openmm-opes
3
+ Version: 0.1.0
4
+ Summary: OpenMM implementation of On-the-fly Probability Enhanced Sampling (OPES)
5
+ Author: Charlles Abreu
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy>=1.24
11
+ Requires-Dist: scipy>=1.10
12
+ Requires-Dist: openmm>=8.1
13
+ Provides-Extra: dev
14
+ Requires-Dist: pytest>=7.4; extra == "dev"
15
+ Requires-Dist: ruff>=0.16; extra == "dev"
16
+ Requires-Dist: ty==0.0.72; extra == "dev"
17
+ Provides-Extra: docs
18
+ Requires-Dist: mkdocs>=1.6; extra == "docs"
19
+ Requires-Dist: mkdocs-material>=9.5; extra == "docs"
20
+ Requires-Dist: mkdocstrings[python]>=0.24; extra == "docs"
21
+ Dynamic: license-file
22
+
23
+ # openmm-opes
24
+
25
+ [![CI](https://github.com/craabreu/openmm-opes/actions/workflows/ci.yml/badge.svg)](https://github.com/craabreu/openmm-opes/actions/workflows/ci.yml)
26
+
27
+ An [OpenMM](https://openmm.org) implementation of On-the-fly Probability
28
+ Enhanced Sampling (OPES) and OPES-explore.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install openmm-opes
34
+ ```
35
+
36
+ ## Usage
37
+
38
+ ```python
39
+ from openmm_opes import OPES
40
+
41
+ sampler = OPES(system, variables, temperature, barrier, frequency=500,
42
+ varianceFrequency=50)
43
+ sampler.step(simulation, 1_000_000)
44
+ fes = sampler.getFreeEnergy()
45
+ ```
46
+
47
+ Full documentation: <https://craabreu.github.io/openmm-opes>
48
+
49
+ ## Origin
50
+
51
+ This library packages and hardens the prototype implementation developed in
52
+ [craabreu/opes-simulations](https://github.com/craabreu/opes-simulations),
53
+ porting its `OPES`/`OnlineKDE` classes into a tested, documented package.
54
+
55
+ ## References
56
+
57
+ - Invernizzi & Parrinello, J. Phys. Chem. Lett. 2020,
58
+ [doi:10.1021/acs.jpclett.0c00497](https://doi.org/10.1021/acs.jpclett.0c00497)
59
+ - Invernizzi & Parrinello, J. Chem. Theory Comput. 2022,
60
+ [doi:10.1021/acs.jctc.2c00152](https://doi.org/10.1021/acs.jctc.2c00152)
61
+
62
+ ## License
63
+
64
+ MIT
@@ -0,0 +1,9 @@
1
+ openmm_opes/__init__.py,sha256=lod_MXaEyOiV-UQE9vDxeTJK5nJlbdTfmedodfmVLbQ,562
2
+ openmm_opes/io.py,sha256=-K1grAYSua2afV1eehrFwLvooDBX5eGTOREmqXpF3AQ,3664
3
+ openmm_opes/kde.py,sha256=85XUrSL5db9e2iRkO7u4P2YaLCdCQC0Gu7qf2wV7UrY,17265
4
+ openmm_opes/opes.py,sha256=-Rge8wDelDI2p8WXFuLwpcGW3CXWkNBVoyaUsDDOD4s,19391
5
+ openmm_opes-0.1.0.dist-info/licenses/LICENSE,sha256=TpNLslqYwa8hLxWToDJdeBd-Ld3Zz0qWo9pi3wCsgDo,1071
6
+ openmm_opes-0.1.0.dist-info/METADATA,sha256=soT_eT2qffOKq7wpcLQDJpnp3p9_JEJKvisW4CSkfQI,1862
7
+ openmm_opes-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ openmm_opes-0.1.0.dist-info/top_level.txt,sha256=Ql0Vg4GnHDMON9G0HFjkvl2B-EtUaX0WXujAMXX2hK0,12
9
+ openmm_opes-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Charlles Abreu
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ openmm_opes