From de8dfaf3690fb47d7b9e5ab40729b2288606ffc3 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 21 Nov 2024 23:31:17 -0700 Subject: [PATCH 001/123] add random walk util, new methods to Polymer() --- mbuild/lib/recipes/polymer.py | 63 +++++++++++++++++++++ mbuild/utils/random_walk.py | 103 ++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 mbuild/utils/random_walk.py diff --git a/mbuild/lib/recipes/polymer.py b/mbuild/lib/recipes/polymer.py index 9209a532d..3107599ea 100644 --- a/mbuild/lib/recipes/polymer.py +++ b/mbuild/lib/recipes/polymer.py @@ -14,6 +14,7 @@ ) from mbuild.lib.atoms import H from mbuild.port import Port +from mbuild.utils.random_walk import random_walk from mbuild.utils.validation import assert_port_exists __all__ = ["Polymer"] @@ -119,6 +120,68 @@ def end_groups(self): """ return self._end_groups + def backbone_vectors(self): + """Yield the consecutive monomer-monomer vectors.""" + for i, mon in enumerate(self.children): + try: + yield self.children[i + 1].center - mon.center + except IndexError: + pass + + def backbone_bond_lengths(self): + """Yield lengths of consecutive monomer-monomer vectors.""" + for vec in self.backbone_vectors(): + yield np.linalg.norm(vec) + + def set_monomer_positions(self, coordinates): + """Shift monomers so that their center of mass matches a set of pre-defined coordinates. + + Parameters + ---------- + coordinates : np.ndarray, shape=(N,3) + Set of x,y,z coordinatess + """ + for i, xyz in enumerate(coordinates): + self.children[i].translate_to(xyz) + + def generate_configuration( + self, + radius=None, + min_angle=np.pi / 2, + max_angle=np.pi, + max_attemps=5000, + energy_minimize=True, + ): + """Update monomer positions to a random configuration. + + Parameters + ---------- + radius : float, default None + Set the minimum distance between monomer centers. + min_angle : float, default pi/2 + Set the minimum angle between 3 sites. + max_angle : float, default pi + Set the maximum angle between 3 sites. + max_attempts : int, default 5000 + The maximum random walk attempts before exiting random walk. + energy_minimize : bool, default True + If True, run energy minimization on resulting structure. + See `mbuild.Compound.energy_minimize()` + """ + avg_bond_L = np.mean([L for L in self.backbone_bond_lengths()]) + if not radius: + radius = avg_bond_L * 1.2 + coords = random_walk( + N=len(self.children), + min_angle=min_angle, + max_angle=max_angle, + bond_L=avg_bond_L, + radius=radius, + ) + self.set_monomer_positions(coords) + if energy_minimize: + self.energy_minimize() + def build(self, n, sequence="A", add_hydrogens=True): """Connect one or more components in a specified sequence. diff --git a/mbuild/utils/random_walk.py b/mbuild/utils/random_walk.py new file mode 100644 index 000000000..c9d268198 --- /dev/null +++ b/mbuild/utils/random_walk.py @@ -0,0 +1,103 @@ +"""Simple random walk algorithm for generating polymer chains.""" + +import numpy as np + + +def random_walk( + N, bond_L, radius, min_angle, max_angle, max_tries=1000, seed=24 +): + """Perform a simple self-avoiding random walk. + + Parameters + ---------- + N : int, required + The number of particles in the random walk. + bond_L : float, required + The fixed bond distance between consecutive sites. + min_angle : float, required + The minimum allowed angle between 3 consecutive sites. + max_angle : float, required + The maximum allowed angle between 3 consecutive sites. + seed : int, default 24 + Random seed used during random walk. + + Returns + ------- + coordinates : np.ndarray, shape=(N, 3) + Final set of coordinates from random walk. + + """ + coordinates = np.zeros((N, 3)) + tries = 1 + count = 0 + while count < N - 1: + current_xyz = coordinates[count] + test_coordinates = np.copy(coordinates) + if count == 0: + new_xyz = _next_coordinate(pos1=current_xyz, bond_L=bond_L) + else: + new_xyz = _next_coordinate( + pos1=current_xyz, + pos2=coordinates[count - 1], + min_angle=min_angle, + max_angle=max_angle, + bond_L=bond_L, + ) + coordinates[count + 1] = new_xyz + + if _check_system( + system_coordinates=coordinates, radius=radius, count=count + 1 + ): + count += 1 + tries += 1 + else: # Next step failed + # Set next coordinate back to (0,0,0) + coordinates[count + 1] = np.zeros(3) + tries += 1 + if tries == max_tries and count < N: + raise RuntimeError( + "The maximum number attempts allowed have passed, and only ", + f"{count} sucsessful attempts were completed.", + ) + + return coordinates + + +def _next_coordinate(bond_L, pos1, pos2=None, min_angle=None, max_angle=None): + if pos2 is None: + phi = np.random.uniform(0, 2 * np.pi) + theta = np.random.uniform(0, np.pi) + next_pos = np.array( + [ + bond_L * np.sin(theta) * np.cos(phi), + bond_L * np.sin(theta) * np.sin(phi), + bond_L * np.cos(theta), + ] + ) + else: + # Get the last bond vector + v1 = pos2 - pos1 + v1_norm = v1 / np.linalg.norm(v1) + theta = np.random.uniform(min_angle, max_angle) + r = np.random.rand(3) - 0.5 # Random vector + r_perp = r - np.dot(r, v1_norm) * v1_norm + r_perp_norm = r_perp / np.linalg.norm(r_perp) + v2 = np.cos(theta) * v1_norm + np.sin(theta) * r_perp_norm + next_pos = v2 * bond_L + + return pos1 + next_pos + + +def _check_system(system_coordinates, radius, count): + if count <= 1: + return True + # Count is the last particle, iterate through all others + # Skip the particle bonded to the current one + current_xyz = system_coordinates[count] + for xyz in system_coordinates[: count - 1]: + d = np.linalg.norm(xyz - current_xyz) + if d < radius: + return False + # Check bonds + bond_vectors = system_coordinates[1:] - system_coordinates[:-1] + return True From 9ef8c8452c377bd65d5bc103cd3196e4a2da94b3 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 22 Nov 2024 00:19:59 -0700 Subject: [PATCH 002/123] add straighten method --- mbuild/lib/recipes/polymer.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/mbuild/lib/recipes/polymer.py b/mbuild/lib/recipes/polymer.py index 3107599ea..aac56ef13 100644 --- a/mbuild/lib/recipes/polymer.py +++ b/mbuild/lib/recipes/polymer.py @@ -144,6 +144,29 @@ def set_monomer_positions(self, coordinates): for i, xyz in enumerate(coordinates): self.children[i].translate_to(xyz) + def straighten(self, axis=(1, 0, 0), energy_minimize=True): + """Shift monomer positions so that the backbone is straight. + + Parameters + ---------- + axis : np.ndarray, shape=(1,3), default (1, 0, 0) + Direction to align the polymer backbone. + energy_minimize : bool, default True + If True, run energy minimization on resulting structure. + See `mbuild.Compound.energy_minimize()` + """ + axis = np.asarray(axis) + avg_bond_L = np.mean([L for L in self.backbone_bond_lengths()]) + coords = np.array( + [ + np.zeros(3) + i * avg_bond_L * axis + for i in range(len(self.children)) + ] + ) + self.set_monomer_positions(coords) + if energy_minimize: + self.energy_minimize() + def generate_configuration( self, radius=None, From f68e313cfad02cbb5c56869346eb3e331d3ed2fe Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 25 Nov 2024 23:06:06 -0700 Subject: [PATCH 003/123] rename file to conformaitons, add lamellae method --- mbuild/lib/recipes/polymer.py | 18 +++--- .../{random_walk.py => conformations.py} | 63 +++++++++++++++++-- 2 files changed, 69 insertions(+), 12 deletions(-) rename mbuild/utils/{random_walk.py => conformations.py} (58%) diff --git a/mbuild/lib/recipes/polymer.py b/mbuild/lib/recipes/polymer.py index aac56ef13..d3f5bccae 100644 --- a/mbuild/lib/recipes/polymer.py +++ b/mbuild/lib/recipes/polymer.py @@ -14,7 +14,7 @@ ) from mbuild.lib.atoms import H from mbuild.port import Port -from mbuild.utils.random_walk import random_walk +from mbuild.utils.conformations import random_walk from mbuild.utils.validation import assert_port_exists __all__ = ["Polymer"] @@ -133,7 +133,7 @@ def backbone_bond_lengths(self): for vec in self.backbone_vectors(): yield np.linalg.norm(vec) - def set_monomer_positions(self, coordinates): + def set_monomer_positions(self, coordinates, energy_minimize=True): """Shift monomers so that their center of mass matches a set of pre-defined coordinates. Parameters @@ -143,6 +143,8 @@ def set_monomer_positions(self, coordinates): """ for i, xyz in enumerate(coordinates): self.children[i].translate_to(xyz) + if energy_minimize: + self.energy_minimize() def straighten(self, axis=(1, 0, 0), energy_minimize=True): """Shift monomer positions so that the backbone is straight. @@ -163,9 +165,9 @@ def straighten(self, axis=(1, 0, 0), energy_minimize=True): for i in range(len(self.children)) ] ) - self.set_monomer_positions(coords) - if energy_minimize: - self.energy_minimize() + self.set_monomer_positions( + coordinates=coords, energy_minimize=energy_minimize + ) def generate_configuration( self, @@ -201,9 +203,9 @@ def generate_configuration( bond_L=avg_bond_L, radius=radius, ) - self.set_monomer_positions(coords) - if energy_minimize: - self.energy_minimize() + self.set_monomer_positions( + coordinates=coords, energy_minimize=energy_minimize + ) def build(self, n, sequence="A", add_hydrogens=True): """Connect one or more components in a specified sequence. diff --git a/mbuild/utils/random_walk.py b/mbuild/utils/conformations.py similarity index 58% rename from mbuild/utils/random_walk.py rename to mbuild/utils/conformations.py index c9d268198..5b9586c6c 100644 --- a/mbuild/utils/random_walk.py +++ b/mbuild/utils/conformations.py @@ -3,20 +3,74 @@ import numpy as np +def lamellae(num_layers, layer_separation, layer_length, bond_L): + """Generate monomer coordinates of a lamellar structure. + + Parameters + ---------- + num_layers : int, required + The number of parallel layers in the structure. + layer_separation : float, (nm), required. + The distance, in nanometers, between parallel layers. + layer_length : float, (nm), required. + The length, in nanometers, of each layer. + bond_L : float, (nm), required. + The monomer-monomer bond length of the backbone. + """ + layer_spacing = np.arange(0, layer_length, bond_L) + r = layer_separation / 2 + arc_length = r * np.pi + arc_num_points = math.floor(arc_length / bond_L) + arc_angle = np.pi / (arc_num_points + 1) + arc_angles = np.linspace(arc_angle, np.pi, arc_num_points, endpoint=False) + coordinates = [] + for i in range(num_layers): + if i % 2 == 0: + layer = np.array( + [np.array([layer_separation * i, y, 0]) for y in layer_spacing] + ) + origin = layer[-1] + np.array([r, 0, 0]) + arc = [] + for theta in arc_angles: + arc.append( + origin + np.array([-np.cos(theta), np.sin(theta), 0]) * r + ) + else: # Go backwards in spacing along x-axis + layer = list( + np.array( + [ + np.array([layer_separation * i, y, 0]) + for y in layer_spacing[::-1] + ] + ) + ) + origin = layer[-1] + np.array([r, 0, 0]) + arc = [] + for theta in arc_angles: + arc.append( + origin + np.array([-np.cos(theta), -np.sin(theta), 0]) * r + ) + if i != num_layers - 1: + coordinates.extend(list(layer) + list(arc)) + else: + coordinates.extend(list(layer)) + return coordinates + + def random_walk( N, bond_L, radius, min_angle, max_angle, max_tries=1000, seed=24 ): - """Perform a simple self-avoiding random walk. + """Generate monomer coordinates resulting from a simple self-avoiding random walk. Parameters ---------- N : int, required The number of particles in the random walk. - bond_L : float, required + bond_L : float, nm, required The fixed bond distance between consecutive sites. - min_angle : float, required + min_angle : float, radians, required The minimum allowed angle between 3 consecutive sites. - max_angle : float, required + max_angle : float, radians, required The maximum allowed angle between 3 consecutive sites. seed : int, default 24 Random seed used during random walk. @@ -27,6 +81,7 @@ def random_walk( Final set of coordinates from random walk. """ + np.random.seed(seed) coordinates = np.zeros((N, 3)) tries = 1 count = 0 From 67f22c6ad9ca372c1f4d370746cd9e7888406415 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 25 Nov 2024 23:22:38 -0700 Subject: [PATCH 004/123] pass in seed param, fix import --- mbuild/lib/recipes/polymer.py | 4 ++++ mbuild/utils/conformations.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/mbuild/lib/recipes/polymer.py b/mbuild/lib/recipes/polymer.py index d3f5bccae..ebe949d17 100644 --- a/mbuild/lib/recipes/polymer.py +++ b/mbuild/lib/recipes/polymer.py @@ -175,6 +175,7 @@ def generate_configuration( min_angle=np.pi / 2, max_angle=np.pi, max_attemps=5000, + seed=42, energy_minimize=True, ): """Update monomer positions to a random configuration. @@ -189,6 +190,8 @@ def generate_configuration( Set the maximum angle between 3 sites. max_attempts : int, default 5000 The maximum random walk attempts before exiting random walk. + seed : int, default 42 + The seed used in the random walk algorithm. energy_minimize : bool, default True If True, run energy minimization on resulting structure. See `mbuild.Compound.energy_minimize()` @@ -202,6 +205,7 @@ def generate_configuration( max_angle=max_angle, bond_L=avg_bond_L, radius=radius, + seed=seed, ) self.set_monomer_positions( coordinates=coords, energy_minimize=energy_minimize diff --git a/mbuild/utils/conformations.py b/mbuild/utils/conformations.py index 5b9586c6c..0a6e8700e 100644 --- a/mbuild/utils/conformations.py +++ b/mbuild/utils/conformations.py @@ -1,5 +1,7 @@ """Simple random walk algorithm for generating polymer chains.""" +import math + import numpy as np From 55282637c848d3776075f184876db2447a45e20d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 26 Nov 2024 12:51:23 -0700 Subject: [PATCH 005/123] clean up lamellar func --- mbuild/utils/conformations.py | 57 +++++++++++++++-------------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/mbuild/utils/conformations.py b/mbuild/utils/conformations.py index 0a6e8700e..052aa66af 100644 --- a/mbuild/utils/conformations.py +++ b/mbuild/utils/conformations.py @@ -20,42 +20,39 @@ def lamellae(num_layers, layer_separation, layer_length, bond_L): The monomer-monomer bond length of the backbone. """ layer_spacing = np.arange(0, layer_length, bond_L) + # Info for generating coords of the curves between layers r = layer_separation / 2 arc_length = r * np.pi arc_num_points = math.floor(arc_length / bond_L) - arc_angle = np.pi / (arc_num_points + 1) + arc_angle = np.pi / (arc_num_points + 1) # incremental angle arc_angles = np.linspace(arc_angle, np.pi, arc_num_points, endpoint=False) coordinates = [] for i in range(num_layers): - if i % 2 == 0: - layer = np.array( - [np.array([layer_separation * i, y, 0]) for y in layer_spacing] - ) + if i % 2 == 0: # Even layer; build from left to right + layer = [ + np.array([layer_separation * i, y, 0]) for y in layer_spacing + ] + # Mid-point between this and next layer; use to get curve coords. origin = layer[-1] + np.array([r, 0, 0]) - arc = [] - for theta in arc_angles: - arc.append( - origin + np.array([-np.cos(theta), np.sin(theta), 0]) * r - ) - else: # Go backwards in spacing along x-axis - layer = list( - np.array( - [ - np.array([layer_separation * i, y, 0]) - for y in layer_spacing[::-1] - ] - ) - ) + arc = [ + origin + np.array([-np.cos(theta), np.sin(theta), 0]) * r + for theta in arc_angles + ] + else: # Odd layer; build from right to left + layer = [ + np.array([layer_separation * i, y, 0]) + for y in layer_spacing[::-1] + ] + # Mid-point between this and next layer; use to get curve coords. origin = layer[-1] + np.array([r, 0, 0]) - arc = [] - for theta in arc_angles: - arc.append( - origin + np.array([-np.cos(theta), -np.sin(theta), 0]) * r - ) + arc = [ + origin + np.array([-np.cos(theta), -np.sin(theta), 0]) * r + for theta in arc_angles + ] if i != num_layers - 1: - coordinates.extend(list(layer) + list(arc)) + coordinates.extend(layer + arc) else: - coordinates.extend(list(layer)) + coordinates.extend(layer) return coordinates @@ -85,11 +82,10 @@ def random_walk( """ np.random.seed(seed) coordinates = np.zeros((N, 3)) - tries = 1 + tries = 0 count = 0 while count < N - 1: current_xyz = coordinates[count] - test_coordinates = np.copy(coordinates) if count == 0: new_xyz = _next_coordinate(pos1=current_xyz, bond_L=bond_L) else: @@ -131,8 +127,7 @@ def _next_coordinate(bond_L, pos1, pos2=None, min_angle=None, max_angle=None): bond_L * np.cos(theta), ] ) - else: - # Get the last bond vector + else: # Get the last bond vector v1 = pos2 - pos1 v1_norm = v1 / np.linalg.norm(v1) theta = np.random.uniform(min_angle, max_angle) @@ -155,6 +150,4 @@ def _check_system(system_coordinates, radius, count): d = np.linalg.norm(xyz - current_xyz) if d < radius: return False - # Check bonds - bond_vectors = system_coordinates[1:] - system_coordinates[:-1] return True From 26572f094107eadb6dae39d84855a6ad367f4cdb Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 27 Nov 2024 20:33:31 -0700 Subject: [PATCH 006/123] move polymer.py out of recipes --- mbuild/__init__.py | 1 + mbuild/lib/recipes/__init__.py | 1 - mbuild/lib/recipes/alkane.py | 2 +- mbuild/{lib/recipes => }/polymer.py | 12 +++++++++++- 4 files changed, 13 insertions(+), 3 deletions(-) rename mbuild/{lib/recipes => }/polymer.py (98%) diff --git a/mbuild/__init__.py b/mbuild/__init__.py index 33dfcd2a1..54f5b1c7b 100644 --- a/mbuild/__init__.py +++ b/mbuild/__init__.py @@ -8,6 +8,7 @@ from mbuild.lattice import Lattice from mbuild.packing import * from mbuild.pattern import * +from mbuild.polymer import Polymer from mbuild.port import Port from mbuild.recipes import recipes diff --git a/mbuild/lib/recipes/__init__.py b/mbuild/lib/recipes/__init__.py index 9d4fc9b3a..2cfecfaf9 100644 --- a/mbuild/lib/recipes/__init__.py +++ b/mbuild/lib/recipes/__init__.py @@ -2,6 +2,5 @@ from mbuild.lib.recipes.alkane import Alkane from mbuild.lib.recipes.monolayer import Monolayer -from mbuild.lib.recipes.polymer import Polymer from mbuild.lib.recipes.silica_interface import SilicaInterface from mbuild.lib.recipes.tiled_compound import TiledCompound diff --git a/mbuild/lib/recipes/alkane.py b/mbuild/lib/recipes/alkane.py index b0e908403..94d3fb151 100644 --- a/mbuild/lib/recipes/alkane.py +++ b/mbuild/lib/recipes/alkane.py @@ -24,7 +24,7 @@ def __init__(self, n=3, cap_front=True, cap_end=True): if n < 1: raise ValueError("n must be 1 or more") super(Alkane, self).__init__() - from mbuild.lib.recipes import Polymer + from mbuild import Polymer # Handle the case of Methane and Ethane separately if n < 3: diff --git a/mbuild/lib/recipes/polymer.py b/mbuild/polymer.py similarity index 98% rename from mbuild/lib/recipes/polymer.py rename to mbuild/polymer.py index ebe949d17..a097b9231 100644 --- a/mbuild/lib/recipes/polymer.py +++ b/mbuild/polymer.py @@ -120,6 +120,16 @@ def end_groups(self): """ return self._end_groups + @property + def contour_length(self): + """The contour length (nm) of the polymer chain.""" + return sum([length for length in self.backbone_bond_lengths()]) + + @property + def radius_of_gyration(self): + """The radius of gyration of the polymer.""" + return self._radius_of_gyration + def backbone_vectors(self): """Yield the consecutive monomer-monomer vectors.""" for i, mon in enumerate(self.children): @@ -129,7 +139,7 @@ def backbone_vectors(self): pass def backbone_bond_lengths(self): - """Yield lengths of consecutive monomer-monomer vectors.""" + """Yield lengths (nm) of consecutive monomer-monomer vectors.""" for vec in self.backbone_vectors(): yield np.linalg.norm(vec) From d4704dfc91ddfba7e412382224ac6d056d03d3e4 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 27 Nov 2024 20:50:26 -0700 Subject: [PATCH 007/123] remove Polymer from entry points in setup --- setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.py b/setup.py index 1cb7d12f3..e5fdf3718 100644 --- a/setup.py +++ b/setup.py @@ -81,7 +81,6 @@ def compile_proto(protoc): "mbuild.plugins": [ "Alkane = mbuild.lib.recipes.alkane:Alkane", "Monolayer = mbuild.lib.recipes.monolayer:Monolayer", - "Polymer = mbuild.lib.recipes.polymer:Polymer", "SilicaInterface = mbuild.lib.recipes.silica_interface:SilicaInterface", "TiledCompound = mbuild.lib.recipes.tiled_compound:TiledCompound", ] From 2a4e002724dc87f1483af43e12a9595c679fb56b Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 27 Nov 2024 21:36:22 -0700 Subject: [PATCH 008/123] clean up random walk --- mbuild/utils/conformations.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/mbuild/utils/conformations.py b/mbuild/utils/conformations.py index 052aa66af..e8625732b 100644 --- a/mbuild/utils/conformations.py +++ b/mbuild/utils/conformations.py @@ -59,7 +59,7 @@ def lamellae(num_layers, layer_separation, layer_length, bond_L): def random_walk( N, bond_L, radius, min_angle, max_angle, max_tries=1000, seed=24 ): - """Generate monomer coordinates resulting from a simple self-avoiding random walk. + """Generate chain coordinates resulting from a simple self-avoiding random walk. Parameters ---------- @@ -71,6 +71,8 @@ def random_walk( The minimum allowed angle between 3 consecutive sites. max_angle : float, radians, required The maximum allowed angle between 3 consecutive sites. + max_tries : int, default 1000 + The maximum number of attemps to complete the random walk. seed : int, default 24 Random seed used during random walk. @@ -81,21 +83,19 @@ def random_walk( """ np.random.seed(seed) + # First coord is always [0,0,0], next pos is always accepted. coordinates = np.zeros((N, 3)) + coordinates[1] = _next_coordinate(pos1=coordinates[0], bond_L=bond_L) tries = 0 - count = 0 + count = 1 # Start at 1; we already have 2 accepted moves while count < N - 1: - current_xyz = coordinates[count] - if count == 0: - new_xyz = _next_coordinate(pos1=current_xyz, bond_L=bond_L) - else: - new_xyz = _next_coordinate( - pos1=current_xyz, - pos2=coordinates[count - 1], - min_angle=min_angle, - max_angle=max_angle, - bond_L=bond_L, - ) + new_xyz = _next_coordinate( + pos1=coordinates[count], + pos2=coordinates[count - 1], + min_angle=min_angle, + max_angle=max_angle, + bond_L=bond_L, + ) coordinates[count + 1] = new_xyz if _check_system( @@ -103,14 +103,14 @@ def random_walk( ): count += 1 tries += 1 - else: # Next step failed - # Set next coordinate back to (0,0,0) + else: # Next step failed. Set next coordinate back to (0,0,0). coordinates[count + 1] = np.zeros(3) tries += 1 if tries == max_tries and count < N: raise RuntimeError( "The maximum number attempts allowed have passed, and only ", f"{count} sucsessful attempts were completed.", + "Try changing the parameters and running again.", ) return coordinates @@ -127,7 +127,7 @@ def _next_coordinate(bond_L, pos1, pos2=None, min_angle=None, max_angle=None): bond_L * np.cos(theta), ] ) - else: # Get the last bond vector + else: # Get the last bond vector, use angle range with last 2 coords. v1 = pos2 - pos1 v1_norm = v1 / np.linalg.norm(v1) theta = np.random.uniform(min_angle, max_angle) @@ -141,8 +141,6 @@ def _next_coordinate(bond_L, pos1, pos2=None, min_angle=None, max_angle=None): def _check_system(system_coordinates, radius, count): - if count <= 1: - return True # Count is the last particle, iterate through all others # Skip the particle bonded to the current one current_xyz = system_coordinates[count] From dc6b8cf870d0c3722a6616aa8767b8d0a5797e10 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 5 Dec 2024 11:14:42 -0700 Subject: [PATCH 009/123] remove Rg method from polymer class for now --- mbuild/polymer.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index a097b9231..8bf242e43 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -125,11 +125,6 @@ def contour_length(self): """The contour length (nm) of the polymer chain.""" return sum([length for length in self.backbone_bond_lengths()]) - @property - def radius_of_gyration(self): - """The radius of gyration of the polymer.""" - return self._radius_of_gyration - def backbone_vectors(self): """Yield the consecutive monomer-monomer vectors.""" for i, mon in enumerate(self.children): From cc0a3b68029979bd520bdd723273840f3e0a0bea Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sun, 19 Jan 2025 07:12:41 -0700 Subject: [PATCH 010/123] change file location --- mbuild/{utils => }/conformations.py | 0 mbuild/polymer.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename mbuild/{utils => }/conformations.py (100%) diff --git a/mbuild/utils/conformations.py b/mbuild/conformations.py similarity index 100% rename from mbuild/utils/conformations.py rename to mbuild/conformations.py diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 8bf242e43..efba9c7b3 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -6,6 +6,7 @@ from mbuild import clone from mbuild.compound import Compound +from mbuild.conformations import random_walk from mbuild.coordinate_transform import ( force_overlap, x_axis_transform, @@ -14,7 +15,6 @@ ) from mbuild.lib.atoms import H from mbuild.port import Port -from mbuild.utils.conformations import random_walk from mbuild.utils.validation import assert_port_exists __all__ = ["Polymer"] From 406c700ebb79cb2c2458805bf9556dbc375adb7e Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 13 Feb 2025 22:46:09 -0700 Subject: [PATCH 011/123] remove polymer from plugins during install --- mbuild/polymer.py | 9 +++++++++ pyproject.toml | 1 - 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index a66cb690a..02c2985b4 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -321,6 +321,15 @@ def build(self, n, sequence="A", add_hydrogens=True): if id(port) not in port_ids: self.remove(port) + def build_lamellae(self, num_layers, layer_length, layer_separation): + pass + + def build_random_configuration(self, n, min_angle, max_angle, radius, seed=42): + pass + + def build_strain_chain(self, n, axis=(1, 0, 0)): + pass + def add_monomer( self, compound, diff --git a/pyproject.toml b/pyproject.toml index 9d51a1c21..72d787167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,5 @@ version = {attr = "mbuild.__version__"} [project.entry-points."mbuild.plugins"] Alkane = "mbuild.lib.recipes.alkane:Alkane" Monolayer = "mbuild.lib.recipes.monolayer:Monolayer" -Polymer = "mbuild.lib.recipes.polymer:Polymer" SilicaInterface = "mbuild.lib.recipes.silica_interface:SilicaInterface" TiledCompound = "mbuild.lib.recipes.tiled_compound:TiledCompound" From 00c30ea861aecd44818dcedb4d4ecb51c973d7af Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 19 Feb 2025 15:37:05 -0700 Subject: [PATCH 012/123] add new build methods --- mbuild/polymer.py | 93 ++++++++++++++++++++++++----------------------- 1 file changed, 47 insertions(+), 46 deletions(-) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 02c2985b4..d0ceae8a1 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -6,7 +6,7 @@ from mbuild import clone from mbuild.compound import Compound -from mbuild.conformations import random_walk +from mbuild.conformations import lamellae, random_walk from mbuild.coordinate_transform import ( force_overlap, x_axis_transform, @@ -168,46 +168,6 @@ def straighten(self, axis=(1, 0, 0), energy_minimize=True): ) self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def generate_configuration( - self, - radius=None, - min_angle=np.pi / 2, - max_angle=np.pi, - max_attemps=5000, - seed=42, - energy_minimize=True, - ): - """Update monomer positions to a random configuration. - - Parameters - ---------- - radius : float, default None - Set the minimum distance between monomer centers. - min_angle : float, default pi/2 - Set the minimum angle between 3 sites. - max_angle : float, default pi - Set the maximum angle between 3 sites. - max_attempts : int, default 5000 - The maximum random walk attempts before exiting random walk. - seed : int, default 42 - The seed used in the random walk algorithm. - energy_minimize : bool, default True - If True, run energy minimization on resulting structure. - See `mbuild.Compound.energy_minimize()` - """ - avg_bond_L = np.mean([L for L in self.backbone_bond_lengths()]) - if not radius: - radius = avg_bond_L * 1.2 - coords = random_walk( - N=len(self.children), - min_angle=min_angle, - max_angle=max_angle, - bond_L=avg_bond_L, - radius=radius, - seed=seed, - ) - self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def build(self, n, sequence="A", add_hydrogens=True): """Connect one or more components in a specified sequence. @@ -321,13 +281,54 @@ def build(self, n, sequence="A", add_hydrogens=True): if id(port) not in port_ids: self.remove(port) - def build_lamellae(self, num_layers, layer_length, layer_separation): - pass + def build_random_configuration( + self, + n, + sequence="A", + min_angle=np.pi / 2, + max_angle=np.pi, + radius=None, + seed=42, + energy_minimize=True, + add_hydrogens=True, + ): + # Build initial polymer chain + self.build(n=n, sequence=sequence, add_hydrogens=add_hydrogens) + # Get new coordinates + avg_bond_L = np.mean([L for L in self.backbone_bond_lengths()]) + if not radius: + radius = avg_bond_L * 1.2 + coords = random_walk( + N=len(self.children), + min_angle=min_angle, + max_angle=max_angle, + bond_L=avg_bond_L, + radius=radius, + seed=seed, + ) + self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def build_random_configuration(self, n, min_angle, max_angle, radius, seed=42): - pass + def build_lamellae( + self, + num_layers, + layer_length, + layer_separation, + bond_L, + sequence="A", + energy_minimize=True, + add_hydrogens=True, + ): + # Get lamellar coords first to determine n monomers + coords = lamellae( + num_layers=num_layers, + layer_length=layer_length, + bond_L=bond_L, + layer_separation=layer_separation, + ) + self.build(n=len(coords), sequence=sequence, add_hydrogens=add_hydrogens) + self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def build_strain_chain(self, n, axis=(1, 0, 0)): + def build_straight_chain(self, n, axis=(1, 0, 0)): pass def add_monomer( From 73d431073876718e00f626768bbf73701a273a92 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 19 Feb 2025 20:48:14 -0700 Subject: [PATCH 013/123] remove build_straight --- mbuild/polymer.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index d0ceae8a1..0f4522cd2 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -328,9 +328,6 @@ def build_lamellae( self.build(n=len(coords), sequence=sequence, add_hydrogens=add_hydrogens) self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def build_straight_chain(self, n, axis=(1, 0, 0)): - pass - def add_monomer( self, compound, From fdc8cf3d191187ec2d7e467e41bd238c41370f71 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 11 Mar 2025 17:01:33 -0600 Subject: [PATCH 014/123] update imports in tests --- mbuild/tests/test_monolayer.py | 3 ++- mbuild/tests/test_polymer.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mbuild/tests/test_monolayer.py b/mbuild/tests/test_monolayer.py index 02988fafb..4c02e3e91 100644 --- a/mbuild/tests/test_monolayer.py +++ b/mbuild/tests/test_monolayer.py @@ -1,8 +1,9 @@ import pytest import mbuild as mb +from mbuild import Polymer from mbuild.lib.atoms import H -from mbuild.lib.recipes import Monolayer, Polymer +from mbuild.lib.recipes import Monolayer from mbuild.lib.surfaces import Betacristobalite from mbuild.tests.base_test import BaseTest diff --git a/mbuild/tests/test_polymer.py b/mbuild/tests/test_polymer.py index bcea26d55..85b15d18f 100644 --- a/mbuild/tests/test_polymer.py +++ b/mbuild/tests/test_polymer.py @@ -4,7 +4,7 @@ import pytest import mbuild as mb -from mbuild.lib.recipes import Polymer +from mbuild import Polymer from mbuild.tests.base_test import BaseTest From 43fb673535c18ae37014afef982d994d85b8cef1 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 12 Mar 2025 07:34:56 -0600 Subject: [PATCH 015/123] update doc strings --- mbuild/conformations.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/mbuild/conformations.py b/mbuild/conformations.py index e8625732b..0dfb549d7 100644 --- a/mbuild/conformations.py +++ b/mbuild/conformations.py @@ -29,9 +29,7 @@ def lamellae(num_layers, layer_separation, layer_length, bond_L): coordinates = [] for i in range(num_layers): if i % 2 == 0: # Even layer; build from left to right - layer = [ - np.array([layer_separation * i, y, 0]) for y in layer_spacing - ] + layer = [np.array([layer_separation * i, y, 0]) for y in layer_spacing] # Mid-point between this and next layer; use to get curve coords. origin = layer[-1] + np.array([r, 0, 0]) arc = [ @@ -40,8 +38,7 @@ def lamellae(num_layers, layer_separation, layer_length, bond_L): ] else: # Odd layer; build from right to left layer = [ - np.array([layer_separation * i, y, 0]) - for y in layer_spacing[::-1] + np.array([layer_separation * i, y, 0]) for y in layer_spacing[::-1] ] # Mid-point between this and next layer; use to get curve coords. origin = layer[-1] + np.array([r, 0, 0]) @@ -56,9 +53,7 @@ def lamellae(num_layers, layer_separation, layer_length, bond_L): return coordinates -def random_walk( - N, bond_L, radius, min_angle, max_angle, max_tries=1000, seed=24 -): +def random_walk(N, bond_L, radius, min_angle, max_angle, max_tries=1000, seed=24): """Generate chain coordinates resulting from a simple self-avoiding random walk. Parameters @@ -67,6 +62,8 @@ def random_walk( The number of particles in the random walk. bond_L : float, nm, required The fixed bond distance between consecutive sites. + radius : float, nm, required + Defines the monomer radius used when checking for overlapping sites. min_angle : float, radians, required The minimum allowed angle between 3 consecutive sites. max_angle : float, radians, required From 5a5eda7bf1d984b545e4a2073ce1ba82bd21c546 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 12 Mar 2025 23:03:04 -0600 Subject: [PATCH 016/123] move all energy minimization stuff to its own file, move polymer, update tests --- mbuild/__init__.py | 1 + mbuild/compound.py | 711 +-------------------------- mbuild/lib/recipes/__init__.py | 1 - mbuild/lib/recipes/alkane.py | 2 +- mbuild/path.py | 6 + mbuild/{lib/recipes => }/polymer.py | 0 mbuild/simulation.py | 730 ++++++++++++++++++++++++++++ mbuild/tests/test_compound.py | 293 ----------- mbuild/tests/test_monolayer.py | 3 +- mbuild/tests/test_polymer.py | 2 +- mbuild/tests/test_simulation.py | 349 +++++++++++++ pyproject.toml | 1 - 12 files changed, 1091 insertions(+), 1008 deletions(-) create mode 100644 mbuild/path.py rename mbuild/{lib/recipes => }/polymer.py (100%) create mode 100644 mbuild/simulation.py create mode 100644 mbuild/tests/test_simulation.py diff --git a/mbuild/__init__.py b/mbuild/__init__.py index 3235323cd..52258a217 100644 --- a/mbuild/__init__.py +++ b/mbuild/__init__.py @@ -10,6 +10,7 @@ from mbuild.lattice import Lattice from mbuild.packing import * from mbuild.pattern import * +from mbuild.polymer import Polymer from mbuild.port import Port from mbuild.recipes import recipes diff --git a/mbuild/compound.py b/mbuild/compound.py index 56eb2a522..86331e616 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -15,8 +15,7 @@ import networkx as nx import numpy as np from boltons.setutils import IndexedSet -from ele.element import Element, element_from_name, element_from_symbol -from ele.exceptions import ElementError +from ele.element import Element from treelib import Tree from mbuild import conversion @@ -2022,714 +2021,6 @@ def _kick(self): particle.pos += (np.random.rand(3) - 0.5) / 100 self._update_port_locations(xyz_init) - def energy_minimize( - self, - forcefield="UFF", - steps=1000, - shift_com=True, - anchor=None, - **kwargs, - ): - """Perform an energy minimization on a Compound. - - Default behavior utilizes `Open Babel `_ - to perform an energy minimization/geometry optimization on a Compound by - applying a generic force field - - Can also utilize `OpenMM `_ to energy minimize after - atomtyping a Compound using - `Foyer `_ to apply a forcefield XML - file that contains valid SMARTS strings. - - This function is primarily intended to be used on smaller components, - with sizes on the order of 10's to 100's of particles, as the energy - minimization scales poorly with the number of particles. - - Parameters - ---------- - steps : int, optional, default=1000 - The number of optimization iterations - forcefield : str, optional, default='UFF' - The generic force field to apply to the Compound for minimization. - Valid options are 'MMFF94', 'MMFF94s', ''UFF', 'GAFF', 'Ghemical'. - Please refer to the `Open Babel documentation - `_ - when considering your choice of force field. - Utilizing OpenMM for energy minimization requires a forcefield - XML file with valid SMARTS strings. Please refer to `OpenMM docs - `_ - for more information. - shift_com : bool, optional, default=True - If True, the energy-minimized Compound is translated such that the - center-of-mass is unchanged relative to the initial configuration. - anchor : Compound, optional, default=None - Translates the energy-minimized Compound such that the - position of the anchor Compound is unchanged relative to the - initial configuration. - - - Other Parameters - ---------------- - algorithm : str, optional, default='cg' - The energy minimization algorithm. Valid options are 'steep', 'cg', - and 'md', corresponding to steepest descent, conjugate gradient, and - equilibrium molecular dynamics respectively. - For _energy_minimize_openbabel - fixed_compounds : Compound, optional, default=None - An individual Compound or list of Compounds that will have their - position fixed during energy minimization. Note, positions are fixed - using a restraining potential and thus may change slightly. - Position fixing will apply to all Particles (i.e., atoms) that exist - in the Compound and to particles in any subsequent sub-Compounds. - By default x,y, and z position is fixed. This can be toggled by instead - passing a list containing the Compound and an list or tuple of bool values - corresponding to x,y and z; e.g., [Compound, (True, True, False)] - will fix the x and y position but allow z to be free. - For _energy_minimize_openbabel - ignore_compounds: Compound, optional, default=None - An individual compound or list of Compounds whose underlying particles - will have their positions fixed and not interact with other atoms via - the specified force field during the energy minimization process. - Note, a restraining potential used and thus absolute position may vary - as a result of the energy minimization process. - Interactions of these ignored atoms can be specified by the user, - e.g., by explicitly setting a distance constraint. - For _energy_minimize_openbabel - distance_constraints: list, optional, default=None - A list containing a pair of Compounds as a tuple or list and - a float value specifying the target distance between the two Compounds, e.g.,: - [(compound1, compound2), distance]. - To specify more than one constraint, pass constraints as a 2D list, e.g.,: - [ [(compound1, compound2), distance1], [(compound3, compound4), distance2] ]. - Note, Compounds specified here must represent individual point particles. - For _energy_minimize_openbabel - constraint_factor: float, optional, default=50000.0 - Harmonic springs are used to constrain distances and fix atom positions, where - the resulting energy associated with the spring is scaled by the - constraint_factor; the energy of this spring is considering during the minimization. - As such, very small values of the constraint_factor may result in an energy - minimized state that does not adequately restrain the distance/position of atoms. - For _energy_minimize_openbabel - scale_bonds : float, optional, default=1 - Scales the bond force constant (1 is completely on). - For _energy_minimize_openmm - scale_angles : float, optional, default=1 - Scales the angle force constant (1 is completely on) - For _energy_minimize_openmm - scale_torsions : float, optional, default=1 - Scales the torsional force constants (1 is completely on) - For _energy_minimize_openmm - Note: Only Ryckaert-Bellemans style torsions are currently supported - scale_nonbonded : float, optional, default=1 - Scales epsilon (1 is completely on) - For _energy_minimize_openmm - constraints : str, optional, default="AllBonds" - Specify constraints on the molecule to minimize, options are: - None, "HBonds", "AllBonds", "HAngles" - For _energy_minimize_openmm - - References - ---------- - If using _energy_minimize_openmm(), please cite: - - .. [Eastman2013] P. Eastman, M. S. Friedrichs, J. D. Chodera, - R. J. Radmer, C. M. Bruns, J. P. Ku, K. A. Beauchamp, T. J. Lane, - L.-P. Wang, D. Shukla, T. Tye, M. Houston, T. Stich, C. Klein, - M. R. Shirts, and V. S. Pande. "OpenMM 4: A Reusable, Extensible, - Hardware Independent Library for High Performance Molecular - Simulation." J. Chem. Theor. Comput. 9(1): 461-469. (2013). - - If using _energy_minimize_openbabel(), please cite: - - .. [OBoyle2011] O'Boyle, N.M.; Banck, M.; James, C.A.; Morley, C.; - Vandermeersch, T.; Hutchison, G.R. "Open Babel: An open chemical - toolbox." (2011) J. Cheminf. 3, 33 - - .. [OpenBabel] Open Babel, version X.X.X http://openbabel.org, - (installed Month Year) - - If using the 'MMFF94' force field please also cite the following: - - .. [Halgren1996a] T.A. Halgren, "Merck molecular force field. I. Basis, - form, scope, parameterization, and performance of MMFF94." (1996) - J. Comput. Chem. 17, 490-519 - - .. [Halgren1996b] T.A. Halgren, "Merck molecular force field. II. MMFF94 - van der Waals and electrostatic parameters for intermolecular - interactions." (1996) J. Comput. Chem. 17, 520-552 - - .. [Halgren1996c] T.A. Halgren, "Merck molecular force field. III. - Molecular geometries and vibrational frequencies for MMFF94." (1996) - J. Comput. Chem. 17, 553-586 - - .. [Halgren1996d] T.A. Halgren and R.B. Nachbar, "Merck molecular force - field. IV. Conformational energies and geometries for MMFF94." (1996) - J. Comput. Chem. 17, 587-615 - - .. [Halgren1996e] T.A. Halgren, "Merck molecular force field. V. - Extension of MMFF94 using experimental data, additional computational - data, and empirical rules." (1996) J. Comput. Chem. 17, 616-641 - - If using the 'MMFF94s' force field please cite the above along with: - - .. [Halgren1999] T.A. Halgren, "MMFF VI. MMFF94s option for energy minimization - studies." (1999) J. Comput. Chem. 20, 720-729 - - If using the 'UFF' force field please cite the following: - - .. [Rappe1992] Rappe, A.K., Casewit, C.J., Colwell, K.S., Goddard, W.A. - III, Skiff, W.M. "UFF, a full periodic table force field for - molecular mechanics and molecular dynamics simulations." (1992) - J. Am. Chem. Soc. 114, 10024-10039 - - If using the 'GAFF' force field please cite the following: - - .. [Wang2004] Wang, J., Wolf, R.M., Caldwell, J.W., Kollman, P.A., - Case, D.A. "Development and testing of a general AMBER force field" - (2004) J. Comput. Chem. 25, 1157-1174 - - If using the 'Ghemical' force field please cite the following: - - .. [Hassinen2001] T. Hassinen and M. Perakyla, "New energy terms for - reduced protein models implemented in an off-lattice force field" - (2001) J. Comput. Chem. 22, 1229-1242 - - """ - # TODO: Update mbuild tutorials to provide overview of new features - # Preliminary tutorials: https://github.com/chrisiacovella/mbuild_energy_minimization - com = self.pos - anchor_in_compound = False - if anchor is not None: - # check to see if the anchor exists - # in the Compound to be energy minimized - for succesor in self.successors(): - if id(anchor) == id(succesor): - anchor_in_compound = True - anchor_pos_old = anchor.pos - - if not anchor_in_compound: - raise MBuildError( - f"Anchor: {anchor} is not part of the Compound: {self}" - "that you are trying to energy minimize." - ) - self._kick() - extension = os.path.splitext(forcefield)[-1] - openbabel_ffs = ["MMFF94", "MMFF94s", "UFF", "GAFF", "Ghemical"] - if forcefield in openbabel_ffs: - self._energy_minimize_openbabel( - forcefield=forcefield, steps=steps, **kwargs - ) - else: - tmp_dir = tempfile.mkdtemp() - self.save(os.path.join(tmp_dir, "un-minimized.mol2")) - - if extension == ".xml": - self._energy_minimize_openmm( - tmp_dir, - forcefield_files=forcefield, - forcefield_name=None, - steps=steps, - **kwargs, - ) - else: - self._energy_minimize_openmm( - tmp_dir, - forcefield_files=None, - forcefield_name=forcefield, - steps=steps, - **kwargs, - ) - - self.update_coordinates(os.path.join(tmp_dir, "minimized.pdb")) - - if shift_com: - self.translate_to(com) - - if anchor_in_compound: - anchor_pos_new = anchor.pos - delta = anchor_pos_old - anchor_pos_new - self.translate(delta) - - def _energy_minimize_openmm( - self, - tmp_dir, - forcefield_files=None, - forcefield_name=None, - steps=1000, - scale_bonds=1, - scale_angles=1, - scale_torsions=1, - scale_nonbonded=1, - constraints="AllBonds", - ): - """Perform energy minimization using OpenMM. - - Converts an mBuild Compound to a ParmEd Structure, - applies a forcefield using Foyer, and creates an OpenMM System. - - Parameters - ---------- - forcefield_files : str or list of str, optional, default=None - Forcefield files to load - forcefield_name : str, optional, default=None - Apply a named forcefield to the output file using the `foyer` - package, e.g. 'oplsaa'. `Foyer forcefields` - _ - steps : int, optional, default=1000 - Number of energy minimization iterations - scale_bonds : float, optional, default=1 - Scales the bond force constant (1 is completely on) - scale_angles : float, optiona, default=1 - Scales the angle force constant (1 is completely on) - scale_torsions : float, optional, default=1 - Scales the torsional force constants (1 is completely on) - scale_nonbonded : float, optional, default=1 - Scales epsilon (1 is completely on) - constraints : str, optional, default="AllBonds" - Specify constraints on the molecule to minimize, options are: - None, "HBonds", "AllBonds", "HAngles" - - Notes - ----- - Assumes a particular organization for the force groups - (HarmonicBondForce, HarmonicAngleForce, RBTorsionForce, NonBondedForce) - - References - ---------- - [Eastman2013]_ - """ - foyer = import_("foyer") - - to_parmed = self.to_parmed() - ff = foyer.Forcefield(forcefield_files=forcefield_files, name=forcefield_name) - to_parmed = ff.apply(to_parmed) - - import openmm.unit as u - from openmm.app import AllBonds, HAngles, HBonds - from openmm.app.pdbreporter import PDBReporter - from openmm.app.simulation import Simulation - from openmm.openmm import LangevinIntegrator - - if constraints: - if constraints == "AllBonds": - constraints = AllBonds - elif constraints == "HBonds": - constraints = HBonds - elif constraints == "HAngles": - constraints = HAngles - else: - raise ValueError( - f"Provided constraints value of: {constraints}.\n" - f'Expected "HAngles", "AllBonds" "HBonds".' - ) - system = to_parmed.createSystem( - constraints=constraints - ) # Create an OpenMM System - else: - system = to_parmed.createSystem() # Create an OpenMM System - # Create a Langenvin Integrator in OpenMM - integrator = LangevinIntegrator( - 298 * u.kelvin, 1 / u.picosecond, 0.002 * u.picoseconds - ) - # Create Simulation object in OpenMM - simulation = Simulation(to_parmed.topology, system, integrator) - - # Loop through forces in OpenMM System and set parameters - for force in system.getForces(): - if type(force).__name__ == "HarmonicBondForce": - for bond_index in range(force.getNumBonds()): - atom1, atom2, r0, k = force.getBondParameters(bond_index) - force.setBondParameters( - bond_index, atom1, atom2, r0, k * scale_bonds - ) - force.updateParametersInContext(simulation.context) - - elif type(force).__name__ == "HarmonicAngleForce": - for angle_index in range(force.getNumAngles()): - atom1, atom2, atom3, r0, k = force.getAngleParameters(angle_index) - force.setAngleParameters( - angle_index, atom1, atom2, atom3, r0, k * scale_angles - ) - force.updateParametersInContext(simulation.context) - - elif type(force).__name__ == "RBTorsionForce": - for torsion_index in range(force.getNumTorsions()): - ( - atom1, - atom2, - atom3, - atom4, - c0, - c1, - c2, - c3, - c4, - c5, - ) = force.getTorsionParameters(torsion_index) - force.setTorsionParameters( - torsion_index, - atom1, - atom2, - atom3, - atom4, - c0 * scale_torsions, - c1 * scale_torsions, - c2 * scale_torsions, - c3 * scale_torsions, - c4 * scale_torsions, - c5 * scale_torsions, - ) - force.updateParametersInContext(simulation.context) - - elif type(force).__name__ == "NonbondedForce": - for nb_index in range(force.getNumParticles()): - charge, sigma, epsilon = force.getParticleParameters(nb_index) - force.setParticleParameters( - nb_index, charge, sigma, epsilon * scale_nonbonded - ) - force.updateParametersInContext(simulation.context) - - elif type(force).__name__ == "CMMotionRemover": - pass - - else: - warn( - f"OpenMM Force {type(force).__name__} is " - "not currently supported in _energy_minimize_openmm. " - "This Force will not be updated!" - ) - - simulation.context.setPositions(to_parmed.positions) - # Run energy minimization through OpenMM - simulation.minimizeEnergy(maxIterations=steps) - reporter = PDBReporter(os.path.join(tmp_dir, "minimized.pdb"), 1) - reporter.report(simulation, simulation.context.getState(getPositions=True)) - - def _check_openbabel_constraints( - self, - particle_list, - successors_list, - check_if_particle=False, - ): - """Provide routines commonly used to check constraint inputs.""" - for part in particle_list: - if not isinstance(part, Compound): - raise MBuildError(f"{part} is not a Compound.") - if id(part) != id(self) and id(part) not in successors_list: - raise MBuildError(f"{part} is not a member of Compound {self}.") - - if check_if_particle: - if len(part.children) != 0: - raise MBuildError( - f"{part} does not correspond to an individual particle." - ) - - def _energy_minimize_openbabel( - self, - steps=1000, - algorithm="cg", - forcefield="UFF", - constraint_factor=50000.0, - distance_constraints=None, - fixed_compounds=None, - ignore_compounds=None, - ): - """Perform an energy minimization on a Compound. - - Utilizes Open Babel (http://openbabel.org/docs/dev/) to perform an - energy minimization/geometry optimization on a Compound by applying - a generic force field. - - This function is primarily intended to be used on smaller components, - with sizes on the order of 10's to 100's of particles, as the energy - minimization scales poorly with the number of particles. - - Parameters - ---------- - steps : int, optionl, default=1000 - The number of optimization iterations - algorithm : str, optional, default='cg' - The energy minimization algorithm. Valid options are 'steep', - 'cg', and 'md', corresponding to steepest descent, conjugate - gradient, and equilibrium molecular dynamics respectively. - forcefield : str, optional, default='UFF' - The generic force field to apply to the Compound for minimization. - Valid options are 'MMFF94', 'MMFF94s', ''UFF', 'GAFF', 'Ghemical'. - Please refer to the Open Babel documentation - (http://open-babel.readthedocs.io/en/latest/Forcefields/Overview.html) - when considering your choice of force field. - fixed_compounds : Compound, optional, default=None - An individual Compound or list of Compounds that will have their - position fixed during energy minimization. Note, positions are fixed - using a restraining potential and thus may change slightly. - Position fixing will apply to all Particles (i.e., atoms) that exist - in the Compound and to particles in any subsequent sub-Compounds. - By default x,y, and z position is fixed. This can be toggled by instead - passing a list containing the Compound and a list or tuple of bool values - corresponding to x,y and z; e.g., [Compound, (True, True, False)] - will fix the x and y position but allow z to be free. - ignore_compounds: Compound, optional, default=None - An individual compound or list of Compounds whose underlying particles - will have their positions fixed and not interact with other atoms via - the specified force field during the energy minimization process. - Note, a restraining potential is used and thus absolute position may vary - as a result of the energy minimization process. - Interactions of these ignored atoms can be specified by the user, - e.g., by explicitly setting a distance constraint. - distance_constraints: list, optional, default=None - A list containing a pair of Compounds as a tuple or list and - a float value specifying the target distance between the two Compounds, e.g.,: - [(compound1, compound2), distance]. - To specify more than one constraint, pass constraints as a 2D list, e.g.,: - [ [(compound1, compound2), distance1], [(compound3, compound4), distance2] ]. - Note, Compounds specified here must represent individual point particles. - constraint_factor: float, optional, default=50000.0 - Harmonic springs are used to constrain distances and fix atom positions, where - the resulting energy associated with the spring is scaled by the - constraint_factor; the energy of this spring is considering during the minimization. - As such, very small values of the constraint_factor may result in an energy - minimized state that does not adequately restrain the distance/position of atom(s)e. - - - References - ---------- - [OBoyle2011]_ - [OpenBabel]_ - - If using the 'MMFF94' force field please also cite the following: - [Halgren1996a]_ - [Halgren1996b]_ - [Halgren1996c]_ - [Halgren1996d]_ - [Halgren1996e]_ - - If using the 'MMFF94s' force field please cite the above along with: - [Halgren1999]_ - - If using the 'UFF' force field please cite the following: - [Rappe1992]_ - - If using the 'GAFF' force field please cite the following: - [Wang2001]_ - - If using the 'Ghemical' force field please cite the following: - [Hassinen2001]_ - """ - openbabel = import_("openbabel") - for particle in self.particles(): - if particle.element is None: - try: - particle._element = element_from_symbol(particle.name) - except ElementError: - try: - particle._element = element_from_name(particle.name) - except ElementError: - raise MBuildError( - f"No element assigned to {particle}; element could not be" - f"inferred from particle name {particle.name}. Cannot perform" - "an energy minimization." - ) - # Create a dict containing particle id and associated index to speed up looping - particle_idx = { - id(particle): idx for idx, particle in enumerate(self.particles()) - } - - # A list containing all Compounds ids contained in self. Will be used to check if - # compounds refered to in the constrains are actually in the Compound we are minimizing. - successors_list = [id(compound) for compound in self.successors()] - - # initialize constraints - ob_constraints = openbabel.OBFFConstraints() - - if distance_constraints is not None: - # if a user passes single constraint as a 1-D array, - # i.e., [(p1,p2), 2.0] rather than [[(p1,p2), 2.0]], - # just add it to a list so we can use the same looping code - if len(np.array(distance_constraints, dtype=object).shape) == 1: - distance_constraints = [distance_constraints] - - for con_temp in distance_constraints: - p1 = con_temp[0][0] - p2 = con_temp[0][1] - - self._check_openbabel_constraints( - [p1, p2], successors_list, check_if_particle=True - ) - if id(p1) == id(p2): - raise MBuildError( - f"Cannot create a constraint between a Particle and itself: {p1} {p2} ." - ) - - # openbabel indices start at 1 - pid_1 = particle_idx[id(p1)] + 1 - # openbabel indices start at 1 - pid_2 = particle_idx[id(p2)] + 1 - dist = ( - con_temp[1] * 10.0 - ) # obenbabel uses angstroms, not nm, convert to angstroms - - ob_constraints.AddDistanceConstraint(pid_1, pid_2, dist) - - if fixed_compounds is not None: - # if we are just passed a single Compound, wrap it into - # and array so we can just use the same looping code - if isinstance(fixed_compounds, Compound): - fixed_compounds = [fixed_compounds] - - # if fixed_compounds is a 1-d array and it is of length 2, we need to determine whether it is - # a list of two Compounds or if fixed_compounds[1] should correspond to the directions to constrain - if len(np.array(fixed_compounds, dtype=object).shape) == 1: - if len(fixed_compounds) == 2: - if not isinstance(fixed_compounds[1], Compound): - # if it is not a list of two Compounds, make a 2d array so we can use the same looping code - fixed_compounds = [fixed_compounds] - - for fixed_temp in fixed_compounds: - # if an individual entry is a list, validate the input - if isinstance(fixed_temp, list): - if len(fixed_temp) == 2: - msg1 = ( - "Expected tuple or list of length 3 to set" - "which dimensions to fix motion." - ) - assert isinstance(fixed_temp[1], (list, tuple)), msg1 - - msg2 = ( - "Expected tuple or list of length 3 to set" - "which dimensions to fix motion, " - f"{len(fixed_temp[1])} found." - ) - assert len(fixed_temp[1]) == 3, msg2 - - dims = [dim for dim in fixed_temp[1]] - msg3 = ( - "Expected bool values for which directions are fixed." - f"Found instead {dims}." - ) - assert all(isinstance(dim, bool) for dim in dims), msg3 - - p1 = fixed_temp[0] - - # if fixed_compounds is defined as [[Compound],[Compound]], - # fixed_temp will be a list of length 1 - elif len(fixed_temp) == 1: - p1 = fixed_temp[0] - dims = [True, True, True] - - else: - p1 = fixed_temp - dims = [True, True, True] - - all_true = all(dims) - - self._check_openbabel_constraints([p1], successors_list) - - if len(p1.children) == 0: - pid = particle_idx[id(p1)] + 1 # openbabel indices start at 1 - - if all_true: - ob_constraints.AddAtomConstraint(pid) - else: - if dims[0]: - ob_constraints.AddAtomXConstraint(pid) - if dims[1]: - ob_constraints.AddAtomYConstraint(pid) - if dims[2]: - ob_constraints.AddAtomZConstraint(pid) - else: - for particle in p1.particles(): - pid = ( - particle_idx[id(particle)] + 1 - ) # openbabel indices start at 1 - - if all_true: - ob_constraints.AddAtomConstraint(pid) - else: - if dims[0]: - ob_constraints.AddAtomXConstraint(pid) - if dims[1]: - ob_constraints.AddAtomYConstraint(pid) - if dims[2]: - ob_constraints.AddAtomZConstraint(pid) - - if ignore_compounds is not None: - temp1 = np.array(ignore_compounds, dtype=object) - if len(temp1.shape) == 2: - ignore_compounds = list(temp1.reshape(-1)) - - # Since the ignore_compounds can only be passed as a list - # we can check the whole list at once before looping over it - self._check_openbabel_constraints(ignore_compounds, successors_list) - - for ignore in ignore_compounds: - p1 = ignore - if len(p1.children) == 0: - pid = particle_idx[id(p1)] + 1 # openbabel indices start at 1 - ob_constraints.AddIgnore(pid) - - else: - for particle in p1.particles(): - pid = ( - particle_idx[id(particle)] + 1 - ) # openbabel indices start at 1 - ob_constraints.AddIgnore(pid) - - mol = self.to_pybel() - mol = mol.OBMol - - mol.PerceiveBondOrders() - mol.SetAtomTypesPerceived() - - ff = openbabel.OBForceField.FindForceField(forcefield) - if ff is None: - raise MBuildError( - f"Force field '{forcefield}' not supported for energy " - "minimization. Valid force fields are 'MMFF94', " - "'MMFF94s', 'UFF', 'GAFF', and 'Ghemical'." - "" - ) - warn( - "Performing energy minimization using the Open Babel package. " - "Please refer to the documentation to find the appropriate " - f"citations for Open Babel and the {forcefield} force field" - ) - - if ( - distance_constraints is not None - or fixed_compounds is not None - or ignore_compounds is not None - ): - ob_constraints.SetFactor(constraint_factor) - if ff.Setup(mol, ob_constraints) == 0: - raise MBuildError( - "Could not setup forcefield for OpenBabel Optimization." - ) - else: - if ff.Setup(mol) == 0: - raise MBuildError( - "Could not setup forcefield for OpenBabel Optimization." - ) - - if algorithm == "steep": - ff.SteepestDescent(steps) - elif algorithm == "md": - ff.MolecularDynamicsTakeNSteps(steps, 300) - elif algorithm == "cg": - ff.ConjugateGradients(steps) - else: - raise MBuildError( - "Invalid minimization algorithm. Valid options " - "are 'steep', 'cg', and 'md'." - ) - ff.UpdateCoordinates(mol) - - # update the coordinates in the Compound - for i, obatom in enumerate(openbabel.OBMolAtomIter(mol)): - x = obatom.GetX() / 10.0 - y = obatom.GetY() / 10.0 - z = obatom.GetZ() / 10.0 - self[i].pos = np.array([x, y, z]) - def save( self, filename, diff --git a/mbuild/lib/recipes/__init__.py b/mbuild/lib/recipes/__init__.py index 16f148718..84e7607d9 100644 --- a/mbuild/lib/recipes/__init__.py +++ b/mbuild/lib/recipes/__init__.py @@ -3,6 +3,5 @@ from mbuild.lib.recipes.alkane import Alkane from mbuild.lib.recipes.monolayer import Monolayer -from mbuild.lib.recipes.polymer import Polymer from mbuild.lib.recipes.silica_interface import SilicaInterface from mbuild.lib.recipes.tiled_compound import TiledCompound diff --git a/mbuild/lib/recipes/alkane.py b/mbuild/lib/recipes/alkane.py index b0e908403..94d3fb151 100644 --- a/mbuild/lib/recipes/alkane.py +++ b/mbuild/lib/recipes/alkane.py @@ -24,7 +24,7 @@ def __init__(self, n=3, cap_front=True, cap_end=True): if n < 1: raise ValueError("n must be 1 or more") super(Alkane, self).__init__() - from mbuild.lib.recipes import Polymer + from mbuild import Polymer # Handle the case of Methane and Ethane separately if n < 3: diff --git a/mbuild/path.py b/mbuild/path.py new file mode 100644 index 000000000..ac08b3c08 --- /dev/null +++ b/mbuild/path.py @@ -0,0 +1,6 @@ +"""""" + + +class Path: + def __init__(self): + pass diff --git a/mbuild/lib/recipes/polymer.py b/mbuild/polymer.py similarity index 100% rename from mbuild/lib/recipes/polymer.py rename to mbuild/polymer.py diff --git a/mbuild/simulation.py b/mbuild/simulation.py new file mode 100644 index 000000000..8b10fd361 --- /dev/null +++ b/mbuild/simulation.py @@ -0,0 +1,730 @@ +"""Simulation methods that operate on mBuild compounds.""" + +import os +import tempfile +from warnings import warn + +import numpy as np +from ele.element import element_from_name, element_from_symbol +from ele.exceptions import ElementError + +from mbuild import Compound +from mbuild.exceptions import MBuildError +from mbuild.utils.io import import_ + + +def energy_minimize( + compound, + forcefield="UFF", + steps=1000, + shift_com=True, + anchor=None, + **kwargs, +): + """Perform an energy minimization on a Compound. + + Default behavior utilizes `Open Babel `_ + to perform an energy minimization/geometry optimization on a Compound by + applying a generic force field + + Can also utilize `OpenMM `_ to energy minimize after + atomtyping a Compound using + `Foyer `_ to apply a forcefield XML + file that contains valid SMARTS strings. + + This function is primarily intended to be used on smaller components, + with sizes on the order of 10's to 100's of particles, as the energy + minimization scales poorly with the number of particles. + + Parameters + ---------- + compound : mbuid.Compound, required + The compound to perform energy minimization on. + steps : int, optional, default=1000 + The number of optimization iterations + forcefield : str, optional, default='UFF' + The generic force field to apply to the Compound for minimization. + Valid options are 'MMFF94', 'MMFF94s', ''UFF', 'GAFF', 'Ghemical'. + Please refer to the `Open Babel documentation + `_ + when considering your choice of force field. + Utilizing OpenMM for energy minimization requires a forcefield + XML file with valid SMARTS strings. Please refer to `OpenMM docs + `_ + for more information. + shift_com : bool, optional, default=True + If True, the energy-minimized Compound is translated such that the + center-of-mass is unchanged relative to the initial configuration. + anchor : Compound, optional, default=None + Translates the energy-minimized Compound such that the + position of the anchor Compound is unchanged relative to the + initial configuration. + + Other Parameters + ---------------- + algorithm : str, optional, default='cg' + The energy minimization algorithm. Valid options are 'steep', 'cg', + and 'md', corresponding to steepest descent, conjugate gradient, and + equilibrium molecular dynamics respectively. + For _energy_minimize_openbabel + fixed_compounds : Compound, optional, default=None + An individual Compound or list of Compounds that will have their + position fixed during energy minimization. Note, positions are fixed + using a restraining potential and thus may change slightly. + Position fixing will apply to all Particles (i.e., atoms) that exist + in the Compound and to particles in any subsequent sub-Compounds. + By default x,y, and z position is fixed. This can be toggled by instead + passing a list containing the Compound and an list or tuple of bool values + corresponding to x,y and z; e.g., [Compound, (True, True, False)] + will fix the x and y position but allow z to be free. + For _energy_minimize_openbabel + ignore_compounds: Compound, optional, default=None + An individual compound or list of Compounds whose underlying particles + will have their positions fixed and not interact with other atoms via + the specified force field during the energy minimization process. + Note, a restraining potential used and thus absolute position may vary + as a result of the energy minimization process. + Interactions of these ignored atoms can be specified by the user, + e.g., by explicitly setting a distance constraint. + For _energy_minimize_openbabel + distance_constraints: list, optional, default=None + A list containing a pair of Compounds as a tuple or list and + a float value specifying the target distance between the two Compounds, e.g.,: + [(compound1, compound2), distance]. + To specify more than one constraint, pass constraints as a 2D list, e.g.,: + [ [(compound1, compound2), distance1], [(compound3, compound4), distance2] ]. + Note, Compounds specified here must represent individual point particles. + For _energy_minimize_openbabel + constraint_factor: float, optional, default=50000.0 + Harmonic springs are used to constrain distances and fix atom positions, where + the resulting energy associated with the spring is scaled by the + constraint_factor; the energy of this spring is considering during the minimization. + As such, very small values of the constraint_factor may result in an energy + minimized state that does not adequately restrain the distance/position of atoms. + For _energy_minimize_openbabel + scale_bonds : float, optional, default=1 + Scales the bond force constant (1 is completely on). + For _energy_minimize_openmm + scale_angles : float, optional, default=1 + Scales the angle force constant (1 is completely on) + For _energy_minimize_openmm + scale_torsions : float, optional, default=1 + Scales the torsional force constants (1 is completely on) + For _energy_minimize_openmm + Note: Only Ryckaert-Bellemans style torsions are currently supported + scale_nonbonded : float, optional, default=1 + Scales epsilon (1 is completely on) + For _energy_minimize_openmm + constraints : str, optional, default="AllBonds" + Specify constraints on the molecule to minimize, options are: + None, "HBonds", "AllBonds", "HAngles" + For _energy_minimize_openmm + + References + ---------- + If using _energy_minimize_openmm(), please cite: + + .. [Eastman2013] P. Eastman, M. S. Friedrichs, J. D. Chodera, + R. J. Radmer, C. M. Bruns, J. P. Ku, K. A. Beauchamp, T. J. Lane, + L.-P. Wang, D. Shukla, T. Tye, M. Houston, T. Stich, C. Klein, + M. R. Shirts, and V. S. Pande. "OpenMM 4: A Reusable, Extensible, + Hardware Independent Library for High Performance Molecular + Simulation." J. Chem. Theor. Comput. 9(1): 461-469. (2013). + + If using _energy_minimize_openbabel(), please cite: + + .. [OBoyle2011] O'Boyle, N.M.; Banck, M.; James, C.A.; Morley, C.; + Vandermeersch, T.; Hutchison, G.R. "Open Babel: An open chemical + toolbox." (2011) J. Cheminf. 3, 33 + + .. [OpenBabel] Open Babel, version X.X.X http://openbabel.org, + (installed Month Year) + + If using the 'MMFF94' force field please also cite the following: + + .. [Halgren1996a] T.A. Halgren, "Merck molecular force field. I. Basis, + form, scope, parameterization, and performance of MMFF94." (1996) + J. Comput. Chem. 17, 490-519 + + .. [Halgren1996b] T.A. Halgren, "Merck molecular force field. II. MMFF94 + van der Waals and electrostatic parameters for intermolecular + interactions." (1996) J. Comput. Chem. 17, 520-552 + + .. [Halgren1996c] T.A. Halgren, "Merck molecular force field. III. + Molecular geometries and vibrational frequencies for MMFF94." (1996) + J. Comput. Chem. 17, 553-586 + + .. [Halgren1996d] T.A. Halgren and R.B. Nachbar, "Merck molecular force + field. IV. Conformational energies and geometries for MMFF94." (1996) + J. Comput. Chem. 17, 587-615 + + .. [Halgren1996e] T.A. Halgren, "Merck molecular force field. V. + Extension of MMFF94 using experimental data, additional computational + data, and empirical rules." (1996) J. Comput. Chem. 17, 616-641 + + If using the 'MMFF94s' force field please cite the above along with: + + .. [Halgren1999] T.A. Halgren, "MMFF VI. MMFF94s option for energy minimization + studies." (1999) J. Comput. Chem. 20, 720-729 + + If using the 'UFF' force field please cite the following: + + .. [Rappe1992] Rappe, A.K., Casewit, C.J., Colwell, K.S., Goddard, W.A. + III, Skiff, W.M. "UFF, a full periodic table force field for + molecular mechanics and molecular dynamics simulations." (1992) + J. Am. Chem. Soc. 114, 10024-10039 + + If using the 'GAFF' force field please cite the following: + + .. [Wang2004] Wang, J., Wolf, R.M., Caldwell, J.W., Kollman, P.A., + Case, D.A. "Development and testing of a general AMBER force field" + (2004) J. Comput. Chem. 25, 1157-1174 + + If using the 'Ghemical' force field please cite the following: + + .. [Hassinen2001] T. Hassinen and M. Perakyla, "New energy terms for + reduced protein models implemented in an off-lattice force field" + (2001) J. Comput. Chem. 22, 1229-1242 + + """ + # TODO: Update mbuild tutorials to provide overview of new features + # Preliminary tutorials: https://github.com/chrisiacovella/mbuild_energy_minimization + com = compound.pos + anchor_in_compound = False + if anchor is not None: + # check to see if the anchor exists + # in the Compound to be energy minimized + for succesor in compound.successors(): + if id(anchor) == id(succesor): + anchor_in_compound = True + anchor_pos_old = anchor.pos + + if not anchor_in_compound: + raise MBuildError( + f"Anchor: {anchor} is not part of the Compound: {compound}" + "that you are trying to energy minimize." + ) + compound._kick() + extension = os.path.splitext(forcefield)[-1] + openbabel_ffs = ["MMFF94", "MMFF94s", "UFF", "GAFF", "Ghemical"] + if forcefield in openbabel_ffs: + _energy_minimize_openbabel( + compound=compound, forcefield=forcefield, steps=steps, **kwargs + ) + else: + tmp_dir = tempfile.mkdtemp() + compound.save(os.path.join(tmp_dir, "un-minimized.mol2")) + + if extension == ".xml": + _energy_minimize_openmm( + compound=compound, + tmp_dir=tmp_dir, + forcefield_files=forcefield, + forcefield_name=None, + steps=steps, + **kwargs, + ) + else: + _energy_minimize_openmm( + compound=compound, + tmp_dir=tmp_dir, + forcefield_files=None, + forcefield_name=forcefield, + steps=steps, + **kwargs, + ) + + compound.update_coordinates(os.path.join(tmp_dir, "minimized.pdb")) + + if shift_com: + compound.translate_to(com) + + if anchor_in_compound: + anchor_pos_new = anchor.pos + delta = anchor_pos_old - anchor_pos_new + compound.translate(delta) + + +def _energy_minimize_openmm( + compound, + tmp_dir, + forcefield_files=None, + forcefield_name=None, + steps=1000, + scale_bonds=1, + scale_angles=1, + scale_torsions=1, + scale_nonbonded=1, + constraints="AllBonds", +): + """Perform energy minimization using OpenMM. + + Converts an mBuild Compound to a ParmEd Structure, + applies a forcefield using Foyer, and creates an OpenMM System. + + Parameters + ---------- + compound : mbuid.Compound, required + The compound to perform energy minimization on. + forcefield_files : str or list of str, optional, default=None + Forcefield files to load + forcefield_name : str, optional, default=None + Apply a named forcefield to the output file using the `foyer` + package, e.g. 'oplsaa'. `Foyer forcefields` + _ + steps : int, optional, default=1000 + Number of energy minimization iterations + scale_bonds : float, optional, default=1 + Scales the bond force constant (1 is completely on) + scale_angles : float, optiona, default=1 + Scales the angle force constant (1 is completely on) + scale_torsions : float, optional, default=1 + Scales the torsional force constants (1 is completely on) + scale_nonbonded : float, optional, default=1 + Scales epsilon (1 is completely on) + constraints : str, optional, default="AllBonds" + Specify constraints on the molecule to minimize, options are: + None, "HBonds", "AllBonds", "HAngles" + + Notes + ----- + Assumes a particular organization for the force groups + (HarmonicBondForce, HarmonicAngleForce, RBTorsionForce, NonBondedForce) + + References + ---------- + [Eastman2013]_ + """ + foyer = import_("foyer") + + to_parmed = compound.to_parmed() + ff = foyer.Forcefield(forcefield_files=forcefield_files, name=forcefield_name) + to_parmed = ff.apply(to_parmed) + + import openmm.unit as u + from openmm.app import AllBonds, HAngles, HBonds + from openmm.app.pdbreporter import PDBReporter + from openmm.app.simulation import Simulation + from openmm.openmm import LangevinIntegrator + + if constraints: + if constraints == "AllBonds": + constraints = AllBonds + elif constraints == "HBonds": + constraints = HBonds + elif constraints == "HAngles": + constraints = HAngles + else: + raise ValueError( + f"Provided constraints value of: {constraints}.\n" + f'Expected "HAngles", "AllBonds" "HBonds".' + ) + system = to_parmed.createSystem( + constraints=constraints + ) # Create an OpenMM System + else: + system = to_parmed.createSystem() # Create an OpenMM System + # Create a Langenvin Integrator in OpenMM + integrator = LangevinIntegrator( + 298 * u.kelvin, 1 / u.picosecond, 0.002 * u.picoseconds + ) + # Create Simulation object in OpenMM + simulation = Simulation(to_parmed.topology, system, integrator) + + # Loop through forces in OpenMM System and set parameters + for force in system.getForces(): + if type(force).__name__ == "HarmonicBondForce": + for bond_index in range(force.getNumBonds()): + atom1, atom2, r0, k = force.getBondParameters(bond_index) + force.setBondParameters(bond_index, atom1, atom2, r0, k * scale_bonds) + force.updateParametersInContext(simulation.context) + + elif type(force).__name__ == "HarmonicAngleForce": + for angle_index in range(force.getNumAngles()): + atom1, atom2, atom3, r0, k = force.getAngleParameters(angle_index) + force.setAngleParameters( + angle_index, atom1, atom2, atom3, r0, k * scale_angles + ) + force.updateParametersInContext(simulation.context) + + elif type(force).__name__ == "RBTorsionForce": + for torsion_index in range(force.getNumTorsions()): + ( + atom1, + atom2, + atom3, + atom4, + c0, + c1, + c2, + c3, + c4, + c5, + ) = force.getTorsionParameters(torsion_index) + force.setTorsionParameters( + torsion_index, + atom1, + atom2, + atom3, + atom4, + c0 * scale_torsions, + c1 * scale_torsions, + c2 * scale_torsions, + c3 * scale_torsions, + c4 * scale_torsions, + c5 * scale_torsions, + ) + force.updateParametersInContext(simulation.context) + + elif type(force).__name__ == "NonbondedForce": + for nb_index in range(force.getNumParticles()): + charge, sigma, epsilon = force.getParticleParameters(nb_index) + force.setParticleParameters( + nb_index, charge, sigma, epsilon * scale_nonbonded + ) + force.updateParametersInContext(simulation.context) + + elif type(force).__name__ == "CMMotionRemover": + pass + + else: + warn( + f"OpenMM Force {type(force).__name__} is " + "not currently supported in _energy_minimize_openmm. " + "This Force will not be updated!" + ) + + simulation.context.setPositions(to_parmed.positions) + # Run energy minimization through OpenMM + simulation.minimizeEnergy(maxIterations=steps) + reporter = PDBReporter(os.path.join(tmp_dir, "minimized.pdb"), 1) + reporter.report(simulation, simulation.context.getState(getPositions=True)) + + +def _check_openbabel_constraints( + compound, + particle_list, + successors_list, + check_if_particle=False, +): + """Provide routines commonly used to check constraint inputs.""" + for part in particle_list: + if not isinstance(part, Compound): + raise MBuildError(f"{part} is not a Compound.") + if id(part) != id(compound) and id(part) not in successors_list: + raise MBuildError(f"{part} is not a member of Compound {compound}.") + + if check_if_particle: + if len(part.children) != 0: + raise MBuildError( + f"{part} does not correspond to an individual particle." + ) + + +def _energy_minimize_openbabel( + compound, + steps=1000, + algorithm="cg", + forcefield="UFF", + constraint_factor=50000.0, + distance_constraints=None, + fixed_compounds=None, + ignore_compounds=None, +): + """Perform an energy minimization on a Compound. + + Utilizes Open Babel (http://openbabel.org/docs/dev/) to perform an + energy minimization/geometry optimization on a Compound by applying + a generic force field. + + This function is primarily intended to be used on smaller components, + with sizes on the order of 10's to 100's of particles, as the energy + minimization scales poorly with the number of particles. + + Parameters + ---------- + compound : mbuid.Compound, required + The compound to perform energy minimization on. + steps : int, optionl, default=1000 + The number of optimization iterations + algorithm : str, optional, default='cg' + The energy minimization algorithm. Valid options are 'steep', + 'cg', and 'md', corresponding to steepest descent, conjugate + gradient, and equilibrium molecular dynamics respectively. + forcefield : str, optional, default='UFF' + The generic force field to apply to the Compound for minimization. + Valid options are 'MMFF94', 'MMFF94s', ''UFF', 'GAFF', 'Ghemical'. + Please refer to the Open Babel documentation + (http://open-babel.readthedocs.io/en/latest/Forcefields/Overview.html) + when considering your choice of force field. + fixed_compounds : Compound, optional, default=None + An individual Compound or list of Compounds that will have their + position fixed during energy minimization. Note, positions are fixed + using a restraining potential and thus may change slightly. + Position fixing will apply to all Particles (i.e., atoms) that exist + in the Compound and to particles in any subsequent sub-Compounds. + By default x,y, and z position is fixed. This can be toggled by instead + passing a list containing the Compound and a list or tuple of bool values + corresponding to x,y and z; e.g., [Compound, (True, True, False)] + will fix the x and y position but allow z to be free. + ignore_compounds: Compound, optional, default=None + An individual compound or list of Compounds whose underlying particles + will have their positions fixed and not interact with other atoms via + the specified force field during the energy minimization process. + Note, a restraining potential is used and thus absolute position may vary + as a result of the energy minimization process. + Interactions of these ignored atoms can be specified by the user, + e.g., by explicitly setting a distance constraint. + distance_constraints: list, optional, default=None + A list containing a pair of Compounds as a tuple or list and + a float value specifying the target distance between the two Compounds, e.g.,: + [(compound1, compound2), distance]. + To specify more than one constraint, pass constraints as a 2D list, e.g.,: + [ [(compound1, compound2), distance1], [(compound3, compound4), distance2] ]. + Note, Compounds specified here must represent individual point particles. + constraint_factor: float, optional, default=50000.0 + Harmonic springs are used to constrain distances and fix atom positions, where + the resulting energy associated with the spring is scaled by the + constraint_factor; the energy of this spring is considering during the minimization. + As such, very small values of the constraint_factor may result in an energy + minimized state that does not adequately restrain the distance/position of atom(s)e. + + + References + ---------- + [OBoyle2011]_ + [OpenBabel]_ + + If using the 'MMFF94' force field please also cite the following: + [Halgren1996a]_ + [Halgren1996b]_ + [Halgren1996c]_ + [Halgren1996d]_ + [Halgren1996e]_ + + If using the 'MMFF94s' force field please cite the above along with: + [Halgren1999]_ + + If using the 'UFF' force field please cite the following: + [Rappe1992]_ + + If using the 'GAFF' force field please cite the following: + [Wang2001]_ + + If using the 'Ghemical' force field please cite the following: + [Hassinen2001]_ + """ + openbabel = import_("openbabel") + for particle in compound.particles(): + if particle.element is None: + try: + particle._element = element_from_symbol(particle.name) + except ElementError: + try: + particle._element = element_from_name(particle.name) + except ElementError: + raise MBuildError( + f"No element assigned to {particle}; element could not be" + f"inferred from particle name {particle.name}. Cannot perform" + "an energy minimization." + ) + # Create a dict containing particle id and associated index to speed up looping + particle_idx = { + id(particle): idx for idx, particle in enumerate(compound.particles()) + } + + # A list containing all Compounds ids contained in compound. Will be used to check if + # compounds refered to in the constrains are actually in the Compound we are minimizing. + successors_list = [id(comp) for comp in compound.successors()] + + # initialize constraints + ob_constraints = openbabel.OBFFConstraints() + + if distance_constraints is not None: + # if a user passes single constraint as a 1-D array, + # i.e., [(p1,p2), 2.0] rather than [[(p1,p2), 2.0]], + # just add it to a list so we can use the same looping code + if len(np.array(distance_constraints, dtype=object).shape) == 1: + distance_constraints = [distance_constraints] + + for con_temp in distance_constraints: + p1 = con_temp[0][0] + p2 = con_temp[0][1] + + _check_openbabel_constraints( + compound=compound, + particle_list=[p1, p2], + successors_list=successors_list, + check_if_particle=True, + ) + if id(p1) == id(p2): + raise MBuildError( + f"Cannot create a constraint between a Particle and itself: {p1} {p2} ." + ) + + # openbabel indices start at 1 + pid_1 = particle_idx[id(p1)] + 1 + # openbabel indices start at 1 + pid_2 = particle_idx[id(p2)] + 1 + dist = ( + con_temp[1] * 10.0 + ) # obenbabel uses angstroms, not nm, convert to angstroms + + ob_constraints.AddDistanceConstraint(pid_1, pid_2, dist) + + if fixed_compounds is not None: + # if we are just passed a single Compound, wrap it into + # and array so we can just use the same looping code + if isinstance(fixed_compounds, Compound): + fixed_compounds = [fixed_compounds] + + # if fixed_compounds is a 1-d array and it is of length 2, we need to determine whether it is + # a list of two Compounds or if fixed_compounds[1] should correspond to the directions to constrain + if len(np.array(fixed_compounds, dtype=object).shape) == 1: + if len(fixed_compounds) == 2: + if not isinstance(fixed_compounds[1], Compound): + # if it is not a list of two Compounds, make a 2d array so we can use the same looping code + fixed_compounds = [fixed_compounds] + + for fixed_temp in fixed_compounds: + # if an individual entry is a list, validate the input + if isinstance(fixed_temp, list): + if len(fixed_temp) == 2: + msg1 = ( + "Expected tuple or list of length 3 to set" + "which dimensions to fix motion." + ) + assert isinstance(fixed_temp[1], (list, tuple)), msg1 + + msg2 = ( + "Expected tuple or list of length 3 to set" + "which dimensions to fix motion, " + f"{len(fixed_temp[1])} found." + ) + assert len(fixed_temp[1]) == 3, msg2 + + dims = [dim for dim in fixed_temp[1]] + msg3 = ( + "Expected bool values for which directions are fixed." + f"Found instead {dims}." + ) + assert all(isinstance(dim, bool) for dim in dims), msg3 + + p1 = fixed_temp[0] + + # if fixed_compounds is defined as [[Compound],[Compound]], + # fixed_temp will be a list of length 1 + elif len(fixed_temp) == 1: + p1 = fixed_temp[0] + dims = [True, True, True] + + else: + p1 = fixed_temp + dims = [True, True, True] + + all_true = all(dims) + + _check_openbabel_constraints( + compound=compound, particle_list=[p1], successors_list=successors_list + ) + + if len(p1.children) == 0: + pid = particle_idx[id(p1)] + 1 # openbabel indices start at 1 + + if all_true: + ob_constraints.AddAtomConstraint(pid) + else: + if dims[0]: + ob_constraints.AddAtomXConstraint(pid) + if dims[1]: + ob_constraints.AddAtomYConstraint(pid) + if dims[2]: + ob_constraints.AddAtomZConstraint(pid) + else: + for particle in p1.particles(): + pid = particle_idx[id(particle)] + 1 # openbabel indices start at 1 + + if all_true: + ob_constraints.AddAtomConstraint(pid) + else: + if dims[0]: + ob_constraints.AddAtomXConstraint(pid) + if dims[1]: + ob_constraints.AddAtomYConstraint(pid) + if dims[2]: + ob_constraints.AddAtomZConstraint(pid) + + if ignore_compounds is not None: + temp1 = np.array(ignore_compounds, dtype=object) + if len(temp1.shape) == 2: + ignore_compounds = list(temp1.reshape(-1)) + + # Since the ignore_compounds can only be passed as a list + # we can check the whole list at once before looping over it + _check_openbabel_constraints( + compound=compound, + particle_list=ignore_compounds, + successors_list=successors_list, + ) + + for ignore in ignore_compounds: + p1 = ignore + if len(p1.children) == 0: + pid = particle_idx[id(p1)] + 1 # openbabel indices start at 1 + ob_constraints.AddIgnore(pid) + + else: + for particle in p1.particles(): + pid = particle_idx[id(particle)] + 1 # openbabel indices start at 1 + ob_constraints.AddIgnore(pid) + + mol = compound.to_pybel() + mol = mol.OBMol + + mol.PerceiveBondOrders() + mol.SetAtomTypesPerceived() + + ff = openbabel.OBForceField.FindForceField(forcefield) + if ff is None: + raise MBuildError( + f"Force field '{forcefield}' not supported for energy " + "minimization. Valid force fields are 'MMFF94', " + "'MMFF94s', 'UFF', 'GAFF', and 'Ghemical'." + "" + ) + warn( + "Performing energy minimization using the Open Babel package. " + "Please refer to the documentation to find the appropriate " + f"citations for Open Babel and the {forcefield} force field" + ) + + if ( + distance_constraints is not None + or fixed_compounds is not None + or ignore_compounds is not None + ): + ob_constraints.SetFactor(constraint_factor) + if ff.Setup(mol, ob_constraints) == 0: + raise MBuildError("Could not setup forcefield for OpenBabel Optimization.") + else: + if ff.Setup(mol) == 0: + raise MBuildError("Could not setup forcefield for OpenBabel Optimization.") + + if algorithm == "steep": + ff.SteepestDescent(steps) + elif algorithm == "md": + ff.MolecularDynamicsTakeNSteps(steps, 300) + elif algorithm == "cg": + ff.ConjugateGradients(steps) + else: + raise MBuildError( + "Invalid minimization algorithm. Valid options are 'steep', 'cg', and 'md'." + ) + ff.UpdateCoordinates(mol) + + # update the coordinates in the Compound + for i, obatom in enumerate(openbabel.OBMolAtomIter(mol)): + x = obatom.GetX() / 10.0 + y = obatom.GetY() / 10.0 + z = obatom.GetZ() / 10.0 + compound[i].pos = np.array([x, y, z]) diff --git a/mbuild/tests/test_compound.py b/mbuild/tests/test_compound.py index 87692af9b..b766578da 100644 --- a/mbuild/tests/test_compound.py +++ b/mbuild/tests/test_compound.py @@ -1792,299 +1792,6 @@ def test_none_charge(self): @pytest.mark.skipif( "win" in sys.platform, reason="Unknown issue with Window's Open Babel " ) - def test_energy_minimize(self, octane): - octane.energy_minimize() - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_shift_com(self, octane): - com_old = octane.pos - octane.energy_minimize() - # check to see if COM of energy minimized Compound - # has been shifted back to the original COM - assert np.allclose(com_old, octane.pos) - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_shift_anchor(self, octane): - anchor_compound = octane.labels["chain"].labels["CH3[0]"] - pos_old = anchor_compound.pos - octane.energy_minimize(anchor=anchor_compound) - # check to see if COM of the anchor Compound - # has been shifted back to the original COM - assert np.allclose(pos_old, anchor_compound.pos) - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_fix_compounds(self, octane): - methyl_end0 = octane.labels["chain"].labels["CH3[0]"] - methyl_end1 = octane.labels["chain"].labels["CH3[0]"] - carbon_end = octane.labels["chain"].labels["CH3[0]"].labels["C[0]"] - not_in_compound = mb.Compound(name="H") - - # fix the whole molecule and make sure positions are close - # given stochastic nature and use of restraining springs - # we need to have a pretty loose tolerance for checking - old_com = octane.pos - octane.energy_minimize( - fixed_compounds=octane, shift_com=False, constraint_factor=1e6 - ) - assert np.allclose(octane.pos, old_com, rtol=1e-2, atol=1e-2) - - # primarily focus on checking inputs are parsed correctly - octane.energy_minimize(fixed_compounds=[octane]) - octane.energy_minimize(fixed_compounds=carbon_end) - octane.energy_minimize(fixed_compounds=methyl_end0) - octane.energy_minimize(fixed_compounds=[methyl_end0]) - octane.energy_minimize(fixed_compounds=[methyl_end0, (True, True, True)]) - - octane.energy_minimize(fixed_compounds=[methyl_end0, (True, True, False)]) - octane.energy_minimize(fixed_compounds=[methyl_end0, [True, True, False]]) - octane.energy_minimize(fixed_compounds=[methyl_end0, (True, False, False)]) - octane.energy_minimize(fixed_compounds=[methyl_end0, (False, False, False)]) - - octane.energy_minimize(fixed_compounds=[methyl_end0, methyl_end1]) - octane.energy_minimize(fixed_compounds=[[methyl_end0], [methyl_end1]]) - octane.energy_minimize( - fixed_compounds=[ - [methyl_end0, (True, True, True)], - [methyl_end1, (True, True, True)], - ] - ) - - with pytest.raises(MBuildError): - octane.energy_minimize(fixed_compounds=not_in_compound) - with pytest.raises(MBuildError): - octane.energy_minimize(fixed_compounds=[not_in_compound]) - with pytest.raises(MBuildError): - octane.energy_minimize(fixed_compounds=[12323.3, (True, False, False)]) - with pytest.raises(Exception): - octane.energy_minimize( - fixed_compounds=[methyl_end0, (True, False, False, False)] - ) - with pytest.raises(Exception): - octane.energy_minimize(fixed_compounds=[methyl_end0, True, False, False]) - with pytest.raises(Exception): - octane.energy_minimize(fixed_compounds=[methyl_end0, True]) - with pytest.raises(Exception): - octane.energy_minimize( - fixed_compounds=[methyl_end0, [True, False, False, False]] - ) - with pytest.raises(Exception): - octane.energy_minimize(fixed_compounds=[methyl_end0, (True, False)]) - - with pytest.raises(Exception): - octane.energy_minimize(fixed_compounds=[methyl_end0, (True)]) - - with pytest.raises(Exception): - octane.energy_minimize(fixed_compounds=[methyl_end0, ("True", True, True)]) - with pytest.raises(Exception): - octane.energy_minimize(fixed_compounds=[methyl_end0, (True, "True", True)]) - with pytest.raises(Exception): - octane.energy_minimize(fixed_compounds=[methyl_end0, (True, True, "True")]) - with pytest.raises(Exception): - octane.energy_minimize( - fixed_compounds=[methyl_end0, ("True", True, "True")] - ) - with pytest.raises(Exception): - octane.energy_minimize( - fixed_compounds=[methyl_end0, (True, "True", "True")] - ) - with pytest.raises(Exception): - octane.energy_minimize( - fixed_compounds=[methyl_end0, ("True", "True", True)] - ) - with pytest.raises(Exception): - octane.energy_minimize( - fixed_compounds=[methyl_end0, ("True", "True", "True")] - ) - with pytest.raises(Exception): - octane.energy_minimize(fixed_compounds=[methyl_end0, (123.0, 231, "True")]) - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_ignore_compounds(self, octane): - methyl_end0 = octane.labels["chain"].labels["CH3[0]"] - methyl_end1 = octane.labels["chain"].labels["CH3[1]"] - carbon_end = octane.labels["chain"].labels["CH3[0]"].labels["C[0]"] - not_in_compound = mb.Compound(name="H") - - # fix the whole molecule and make sure positions are close - # given stochastic nature and use of restraining springs - # we need to have a pretty loose tolerance for checking - old_com = octane.pos - octane.energy_minimize( - ignore_compounds=octane, shift_com=False, constraint_factor=1e6 - ) - assert np.allclose(octane.pos, old_com, rtol=1e-2, atol=1e-2) - - # primarily focus on checking inputs are parsed correctly - octane.energy_minimize(ignore_compounds=[octane]) - octane.energy_minimize(ignore_compounds=carbon_end) - octane.energy_minimize(ignore_compounds=methyl_end0) - octane.energy_minimize(ignore_compounds=[methyl_end0]) - octane.energy_minimize(ignore_compounds=[methyl_end0, methyl_end1]) - octane.energy_minimize(ignore_compounds=[[methyl_end0], [methyl_end1]]) - - with pytest.raises(MBuildError): - octane.energy_minimize(ignore_compounds=not_in_compound) - with pytest.raises(MBuildError): - octane.energy_minimize(ignore_compounds=[1231, 123124]) - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_distance_constraints(self, octane): - methyl_end0 = octane.labels["chain"].labels["CH3[0]"] - methyl_end1 = octane.labels["chain"].labels["CH3[1]"] - - carbon_end0 = octane.labels["chain"].labels["CH3[0]"].labels["C[0]"] - carbon_end1 = octane.labels["chain"].labels["CH3[1]"].labels["C[0]"] - h_end0 = octane.labels["chain"].labels["CH3[0]"].labels["H[0]"] - - not_in_compound = mb.Compound(name="H") - - # given stochastic nature and use of restraining springs - # we need to have a pretty loose tolerance for checking - octane.energy_minimize( - distance_constraints=[(carbon_end0, carbon_end1), 0.7], - constraint_factor=1e20, - ) - assert np.allclose( - np.linalg.norm(carbon_end0.pos - carbon_end1.pos), - 0.7, - rtol=1e-2, - atol=1e-2, - ) - - octane.energy_minimize(distance_constraints=[[(carbon_end0, carbon_end1), 0.7]]) - octane.energy_minimize( - distance_constraints=[ - [(carbon_end0, carbon_end1), 0.7], - [(carbon_end0, h_end0), 0.1], - ] - ) - - with pytest.raises(MBuildError): - octane.energy_minimize( - distance_constraints=[(carbon_end0, not_in_compound), 0.7] - ) - with pytest.raises(MBuildError): - octane.energy_minimize( - distance_constraints=[(carbon_end0, carbon_end0), 0.7] - ) - with pytest.raises(MBuildError): - octane.energy_minimize( - distance_constraints=[(methyl_end0, carbon_end1), 0.7] - ) - with pytest.raises(MBuildError): - octane.energy_minimize( - distance_constraints=[(methyl_end0, methyl_end1), 0.7] - ) - - @pytest.mark.skipif(has_openbabel, reason="Open Babel package is installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_openbabel_warn(self, octane): - with pytest.raises(MBuildError): - octane.energy_minimize() - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_ff(self, octane): - for ff in ["UFF", "GAFF", "MMFF94", "MMFF94s", "Ghemical"]: - octane.energy_minimize(forcefield=ff) - with pytest.raises(IOError): - octane.energy_minimize(forcefield="fakeFF") - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_algorithm(self, octane): - for algorithm in ["cg", "steep", "md"]: - octane.energy_minimize(algorithm=algorithm) - with pytest.raises(MBuildError): - octane.energy_minimize(algorithm="fakeAlg") - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_non_element(self, octane): - for particle in octane.particles(): - particle.element = None - # Pass with element inference from names - octane.energy_minimize() - for particle in octane.particles(): - particle.name = "Q" - particle.element = None - - # Fail once names cannot be set as elements - with pytest.raises(MBuildError): - octane.energy_minimize() - - @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") - @pytest.mark.skipif( - "win" in sys.platform, reason="Unknown issue with Window's Open Babel " - ) - def test_energy_minimize_ports(self, octane): - distances = np.round( - [ - octane.min_periodic_distance(port.pos, port.anchor.pos) - for port in octane.all_ports() - ], - 5, - ) - orientations = np.round( - [port.pos - port.anchor.pos for port in octane.all_ports()], 5 - ) - - octane.energy_minimize() - - updated_distances = np.round( - [ - octane.min_periodic_distance(port.pos, port.anchor.pos) - for port in octane.all_ports() - ], - 5, - ) - updated_orientations = np.round( - [port.pos - port.anchor.pos for port in octane.all_ports()], 5 - ) - - assert np.array_equal(distances, updated_distances) - assert np.array_equal(orientations, updated_orientations) - - @pytest.mark.skipif(not has_foyer, reason="Foyer is not installed") - def test_energy_minimize_openmm(self, octane): - octane.energy_minimize(forcefield="oplsaa") - - @pytest.mark.skipif(not has_foyer, reason="Foyer is not installed") - @pytest.mark.parametrize("constraints", ["AllBonds", "HBonds", "HAngles", None]) - def test_energy_minimize_openmm_constraints(self, octane, constraints): - octane.energy_minimize(forcefield="oplsaa", constraints=constraints) - - def test_energy_minimize_openmm_invalid_constraints(self, octane): - with pytest.raises(ValueError): - octane.energy_minimize(forcefield="oplsaa", constraints="boo") - - @pytest.mark.skipif(not has_foyer, reason="Foyer is not installed") - def test_energy_minimize_openmm_xml(self, octane): - octane.energy_minimize(forcefield=get_fn("small_oplsaa.xml")) - def test_clone_outside_containment(self, ch2, ch3): compound = Compound() compound.add(ch2) diff --git a/mbuild/tests/test_monolayer.py b/mbuild/tests/test_monolayer.py index 02988fafb..4c02e3e91 100644 --- a/mbuild/tests/test_monolayer.py +++ b/mbuild/tests/test_monolayer.py @@ -1,8 +1,9 @@ import pytest import mbuild as mb +from mbuild import Polymer from mbuild.lib.atoms import H -from mbuild.lib.recipes import Monolayer, Polymer +from mbuild.lib.recipes import Monolayer from mbuild.lib.surfaces import Betacristobalite from mbuild.tests.base_test import BaseTest diff --git a/mbuild/tests/test_polymer.py b/mbuild/tests/test_polymer.py index bcea26d55..85b15d18f 100644 --- a/mbuild/tests/test_polymer.py +++ b/mbuild/tests/test_polymer.py @@ -4,7 +4,7 @@ import pytest import mbuild as mb -from mbuild.lib.recipes import Polymer +from mbuild import Polymer from mbuild.tests.base_test import BaseTest diff --git a/mbuild/tests/test_simulation.py b/mbuild/tests/test_simulation.py new file mode 100644 index 000000000..c6f216b62 --- /dev/null +++ b/mbuild/tests/test_simulation.py @@ -0,0 +1,349 @@ +import sys + +import numpy as np +import pytest + +import mbuild as mb +from mbuild.exceptions import MBuildError +from mbuild.simulation import energy_minimize +from mbuild.tests.base_test import BaseTest +from mbuild.utils.io import ( + get_fn, + has_foyer, + has_openbabel, +) + + +class TestSimulation(BaseTest): + def test_energy_minimize(self, octane): + energy_minimize(compound=octane) + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_shift_com(self, octane): + com_old = octane.pos + energy_minimize(compound=octane) + # check to see if COM of energy minimized Compound + # has been shifted back to the original COM + assert np.allclose(com_old, octane.pos) + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_shift_anchor(self, octane): + anchor_compound = octane.labels["chain"].labels["CH3[0]"] + pos_old = anchor_compound.pos + energy_minimize(compound=octane, anchor=anchor_compound) + # check to see if COM of the anchor Compound + # has been shifted back to the original COM + assert np.allclose(pos_old, anchor_compound.pos) + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_fix_compounds(self, octane): + methyl_end0 = octane.labels["chain"].labels["CH3[0]"] + methyl_end1 = octane.labels["chain"].labels["CH3[0]"] + carbon_end = octane.labels["chain"].labels["CH3[0]"].labels["C[0]"] + not_in_compound = mb.Compound(name="H") + + # fix the whole molecule and make sure positions are close + # given stochastic nature and use of restraining springs + # we need to have a pretty loose tolerance for checking + old_com = octane.pos + energy_minimize( + compound=octane, + fixed_compounds=octane, + shift_com=False, + constraint_factor=1e6, + ) + assert np.allclose(octane.pos, old_com, rtol=1e-2, atol=1e-2) + + # primarily focus on checking inputs are parsed correctly + energy_minimize(compound=octane, fixed_compounds=[octane]) + energy_minimize(compound=octane, fixed_compounds=carbon_end) + energy_minimize(compound=octane, fixed_compounds=methyl_end0) + energy_minimize(compound=octane, fixed_compounds=[methyl_end0]) + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (True, True, True)] + ) + + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (True, True, False)] + ) + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, [True, True, False]] + ) + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (True, False, False)] + ) + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (False, False, False)] + ) + + energy_minimize(compound=octane, fixed_compounds=[methyl_end0, methyl_end1]) + energy_minimize(compound=octane, fixed_compounds=[[methyl_end0], [methyl_end1]]) + energy_minimize( + compound=octane, + fixed_compounds=[ + [methyl_end0, (True, True, True)], + [methyl_end1, (True, True, True)], + ], + ) + + with pytest.raises(MBuildError): + energy_minimize(compound=octane, fixed_compounds=not_in_compound) + with pytest.raises(MBuildError): + energy_minimize(compound=octane, fixed_compounds=[not_in_compound]) + with pytest.raises(MBuildError): + energy_minimize( + compound=octane, fixed_compounds=[12323.3, (True, False, False)] + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, + fixed_compounds=[methyl_end0, (True, False, False, False)], + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, True, False, False] + ) + with pytest.raises(Exception): + energy_minimize(compound=octane, fixed_compounds=[methyl_end0, True]) + with pytest.raises(Exception): + energy_minimize( + compound=octane, + fixed_compounds=[methyl_end0, [True, False, False, False]], + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (True, False)] + ) + + with pytest.raises(Exception): + energy_minimize(compound=octane, fixed_compounds=[methyl_end0, (True)]) + + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, ("True", True, True)] + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (True, "True", True)] + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (True, True, "True")] + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, ("True", True, "True")] + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (True, "True", "True")] + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, ("True", "True", True)] + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, ("True", "True", "True")] + ) + with pytest.raises(Exception): + energy_minimize( + compound=octane, fixed_compounds=[methyl_end0, (123.0, 231, "True")] + ) + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_ignore_compounds(self, octane): + methyl_end0 = octane.labels["chain"].labels["CH3[0]"] + methyl_end1 = octane.labels["chain"].labels["CH3[1]"] + carbon_end = octane.labels["chain"].labels["CH3[0]"].labels["C[0]"] + not_in_compound = mb.Compound(name="H") + + # fix the whole molecule and make sure positions are close + # given stochastic nature and use of restraining springs + # we need to have a pretty loose tolerance for checking + old_com = octane.pos + energy_minimize( + compound=octane, + ignore_compounds=octane, + shift_com=False, + constraint_factor=1e6, + ) + assert np.allclose(octane.pos, old_com, rtol=1e-2, atol=1e-2) + + # primarily focus on checking inputs are parsed correctly + energy_minimize(compound=octane, ignore_compounds=[octane]) + energy_minimize(compound=octane, ignore_compounds=carbon_end) + energy_minimize(compound=octane, ignore_compounds=methyl_end0) + energy_minimize(compound=octane, ignore_compounds=[methyl_end0]) + energy_minimize(compound=octane, ignore_compounds=[methyl_end0, methyl_end1]) + energy_minimize( + compound=octane, ignore_compounds=[[methyl_end0], [methyl_end1]] + ) + + with pytest.raises(MBuildError): + energy_minimize(compound=octane, ignore_compounds=not_in_compound) + with pytest.raises(MBuildError): + energy_minimize(compound=octane, ignore_compounds=[1231, 123124]) + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_distance_constraints(self, octane): + methyl_end0 = octane.labels["chain"].labels["CH3[0]"] + methyl_end1 = octane.labels["chain"].labels["CH3[1]"] + + carbon_end0 = octane.labels["chain"].labels["CH3[0]"].labels["C[0]"] + carbon_end1 = octane.labels["chain"].labels["CH3[1]"].labels["C[0]"] + h_end0 = octane.labels["chain"].labels["CH3[0]"].labels["H[0]"] + + not_in_compound = mb.Compound(name="H") + + # given stochastic nature and use of restraining springs + # we need to have a pretty loose tolerance for checking + energy_minimize( + compound=octane, + distance_constraints=[(carbon_end0, carbon_end1), 0.7], + constraint_factor=1e20, + ) + assert np.allclose( + np.linalg.norm(carbon_end0.pos - carbon_end1.pos), + 0.7, + rtol=1e-2, + atol=1e-2, + ) + + energy_minimize( + compound=octane, distance_constraints=[[(carbon_end0, carbon_end1), 0.7]] + ) + energy_minimize( + compound=octane, + distance_constraints=[ + [(carbon_end0, carbon_end1), 0.7], + [(carbon_end0, h_end0), 0.1], + ], + ) + + with pytest.raises(MBuildError): + energy_minimize( + compound=octane, + distance_constraints=[(carbon_end0, not_in_compound), 0.7], + ) + with pytest.raises(MBuildError): + energy_minimize( + compound=octane, distance_constraints=[(carbon_end0, carbon_end0), 0.7] + ) + with pytest.raises(MBuildError): + energy_minimize( + compound=octane, distance_constraints=[(methyl_end0, carbon_end1), 0.7] + ) + with pytest.raises(MBuildError): + energy_minimize( + compound=octane, distance_constraints=[(methyl_end0, methyl_end1), 0.7] + ) + + @pytest.mark.skipif(has_openbabel, reason="Open Babel package is installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_openbabel_warn(self, octane): + with pytest.raises(MBuildError): + energy_minimize(compound=octane) + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_ff(self, octane): + for ff in ["UFF", "GAFF", "MMFF94", "MMFF94s", "Ghemical"]: + energy_minimize(compound=octane, forcefield=ff) + with pytest.raises(IOError): + energy_minimize(compound=octane, forcefield="fakeFF") + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_algorithm(self, octane): + for algorithm in ["cg", "steep", "md"]: + energy_minimize(compound=octane, algorithm=algorithm) + with pytest.raises(MBuildError): + energy_minimize(compound=octane, algorithm="fakeAlg") + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_non_element(self, octane): + for particle in octane.particles(): + particle.element = None + # Pass with element inference from names + energy_minimize(compound=octane) + for particle in octane.particles(): + particle.name = "Q" + particle.element = None + + # Fail once names cannot be set as elements + with pytest.raises(MBuildError): + energy_minimize(compound=octane) + + @pytest.mark.skipif(not has_openbabel, reason="Open Babel not installed") + @pytest.mark.skipif( + "win" in sys.platform, reason="Unknown issue with Window's Open Babel " + ) + def test_energy_minimize_ports(self, octane): + distances = np.round( + [ + octane.min_periodic_distance(port.pos, port.anchor.pos) + for port in octane.all_ports() + ], + 5, + ) + orientations = np.round( + [port.pos - port.anchor.pos for port in octane.all_ports()], 5 + ) + + energy_minimize(compound=octane) + + updated_distances = np.round( + [ + octane.min_periodic_distance(port.pos, port.anchor.pos) + for port in octane.all_ports() + ], + 5, + ) + updated_orientations = np.round( + [port.pos - port.anchor.pos for port in octane.all_ports()], 5 + ) + + assert np.array_equal(distances, updated_distances) + assert np.array_equal(orientations, updated_orientations) + + @pytest.mark.skipif(not has_foyer, reason="Foyer is not installed") + def test_energy_minimize_openmm(self, octane): + energy_minimize(compound=octane, forcefield="oplsaa") + + @pytest.mark.skipif(not has_foyer, reason="Foyer is not installed") + @pytest.mark.parametrize("constraints", ["AllBonds", "HBonds", "HAngles", None]) + def test_energy_minimize_openmm_constraints(self, octane, constraints): + energy_minimize(compound=octane, forcefield="oplsaa", constraints=constraints) + + def test_energy_minimize_openmm_invalid_constraints(self, octane): + with pytest.raises(ValueError): + energy_minimize(compound=octane, forcefield="oplsaa", constraints="boo") + + @pytest.mark.skipif(not has_foyer, reason="Foyer is not installed") + def test_energy_minimize_openmm_xml(self, octane): + energy_minimize(compound=octane, forcefield=get_fn("small_oplsaa.xml")) diff --git a/pyproject.toml b/pyproject.toml index 9d51a1c21..72d787167 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,5 @@ version = {attr = "mbuild.__version__"} [project.entry-points."mbuild.plugins"] Alkane = "mbuild.lib.recipes.alkane:Alkane" Monolayer = "mbuild.lib.recipes.monolayer:Monolayer" -Polymer = "mbuild.lib.recipes.polymer:Polymer" SilicaInterface = "mbuild.lib.recipes.silica_interface:SilicaInterface" TiledCompound = "mbuild.lib.recipes.tiled_compound:TiledCompound" From 05c723ec599637977448de294b9a4a9fb73be1ce Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 25 Mar 2025 23:00:15 -0600 Subject: [PATCH 017/123] move chunk of bounding box code to utils, add Path classes --- mbuild/compound.py | 39 +-------- mbuild/path.py | 177 +++++++++++++++++++++++++++++++++++++++ mbuild/utils/geometry.py | 41 +++++++++ 3 files changed, 221 insertions(+), 36 deletions(-) create mode 100644 mbuild/path.py diff --git a/mbuild/compound.py b/mbuild/compound.py index 56eb2a522..1950ad447 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -26,6 +26,7 @@ from mbuild.exceptions import MBuildError from mbuild.periodic_kdtree import PeriodicKDTree from mbuild.utils.decorators import experimental_feature +from mbuild.utils.geometry import bounding_box from mbuild.utils.io import import_, run_from_ipython from mbuild.utils.jsutils import overwrite_nglview_default @@ -1532,40 +1533,7 @@ def get_boundingbox(self, pad_box=None): that are generated from mb.Lattice's and the resulting mb.Lattice.populate method """ - # case where only 1 particle exists - is_one_particle = False - if self.xyz.shape[0] == 1: - is_one_particle = True - - # are any columns all equalivalent values? - # an example of this would be a planar molecule - # example: all z values are 0.0 - # from: https://stackoverflow.com/a/14860884 - # steps: create mask array comparing first value in each column - # use np.all with axis=0 to do row columnar comparision - has_dimension = [True, True, True] - if not is_one_particle: - missing_dimensions = np.all( - np.isclose(self.xyz, self.xyz[0, :], atol=1e-2), - axis=0, - ) - for i, truthy in enumerate(missing_dimensions): - has_dimension[i] = not truthy - - if is_one_particle: - v1 = np.asarray([[1.0, 0.0, 0.0]]) - v2 = np.asarray([[0.0, 1.0, 0.0]]) - v3 = np.asarray([[0.0, 0.0, 1.0]]) - else: - v1 = np.asarray((self.maxs[0] - self.mins[0], 0.0, 0.0)) - v2 = np.asarray((0.0, self.maxs[1] - self.mins[1], 0.0)) - v3 = np.asarray((0.0, 0.0, self.maxs[2] - self.mins[2])) - vecs = [v1, v2, v3] - - # handle any missing dimensions (planar molecules) - for i, dim in enumerate(has_dimension): - if not dim: - vecs[i][i] = 0.1 + vecs = bounding_box(xyz=self.xyz) if pad_box is not None: if isinstance(pad_box, (int, float, str, Sequence)): @@ -1588,8 +1556,7 @@ def get_boundingbox(self, pad_box=None): for dim, val in enumerate(padding): vecs[dim][dim] = vecs[dim][dim] + val - bounding_box = Box.from_vectors(vectors=np.asarray([vecs]).reshape(3, 3)) - return bounding_box + return Box.from_vectors(vectors=np.asarray([vecs]).reshape(3, 3)) def min_periodic_distance(self, xyz0, xyz1): """Vectorized distance calculation considering minimum image. diff --git a/mbuild/path.py b/mbuild/path.py new file mode 100644 index 000000000..3fca7bcad --- /dev/null +++ b/mbuild/path.py @@ -0,0 +1,177 @@ +"""Molecular paths and templates""" + +from abc import ABC, abstractmethod + +import freud +import numpy as np + +from mbuild import Compound +from mbuild.utils.geometry import bounding_box + + +class Path(ABC): + def __init__(self, N=None, max_attempts=10000): + self.max_attempts = max_attempts + self.attempts = 0 + self.compound = None + self.N = N + self.nlist = None + if N: + self.coordinates = np.zeros((N, 3)) + # Not every path will know N ahead of time (Lamellae) + else: + self.coordinates = [] + # Generate dict values now? + # Entry for each node, initialize with empty lists + self.bonds = [] + + """ + A path is basically a bond graph with coordinates/positions + assigned to the nodes. This is kind of what Compound is already. + + The interesting part is building up/creating the path. + This follows an algorithm to generate next coordinates. + Any useful path generation algorithm will include + a rejection/acception step. Basically end up with + monte carlo. + + Is Path basically going to be a simple Monte carlo + class that others can inherit from? + + Classes that inherit from path will have their own + verions of next_coordinate, check path, etc.. + We can define abstract methods here. + + """ + + @abstractmethod + def generate(self): + pass + + def neighbor_list(self, r_max, coordinates=None, box=None): + if coordinates is None: + coordinates = self.coordinates + if box is None: + box = bounding_box(coordinates) + freud_box = freud.box.Box(Lx=box[0], Ly=box[1], Lz=box[2]) + aq = freud.locality.AABBQuery(freud_box, coordinates) + aq_query = aq.query( + query_points=coordinates, + query_args=dict(r_min=0.0, r_max=r_max, exclude_ii=True), + ) + nlist = aq_query.toNeighborList() + return nlist + + def to_compound(self): + compound = Compound() + for xyz in self.coordinates: + compound.add(Compound(name="Bead", pos=xyz)) + for bond_group in self.bonds: + compound.add_bond([compound[bond_group[0]], compound[bond_group[1]]]) + return compound + + def apply_mapping(self): + pass + + def _path_history(self): + pass + + @abstractmethod + def _next_coordinate(self): + pass + + @abstractmethod + def _check_path(self): + pass + + +class HardSphereRandomWalk(Path): + def __init__( + self, + N, + bond_length, + radius, + min_angle, + max_angle, + max_attempts, + seed, + tolerance=1e-5, + ): + self.bond_length = bond_length + self.radius = radius + self.min_angle = min_angle + self.max_angle = max_angle + self.seed = seed + self.tolerance = tolerance + self.count = 0 + super(HardSphereRandomWalk, self).__init__(N=N, max_attempts=max_attempts) + + def generate(self): + np.random.seed(self.seed) + # First move is always accepted + self.coordinates[1] = self._next_coordinate(pos1=self.coordinates[0]) + self.bonds.append([0, 1]) + self.count += 1 # We already have 1 accepted move + while self.count < self.N - 1: + new_xyz = self._next_coordinate( + pos1=self.coordinates[self.count], + pos2=self.coordinates[self.count - 1], + ) + self.coordinates[self.count + 1] = new_xyz + if self._check_path(): + self.bonds.append((self.count, self.count + 1)) + self.count += 1 + self.attempts += 1 + else: + self.coordinates[self.count + 1] = np.zeros(3) + self.attempts += 1 + if self.attempts == self.max_attempts and self.count < self.N: + raise RuntimeError( + "The maximum number attempts allowed have passed, and only ", + f"{self.count} sucsessful attempts were completed.", + "Try changing the parameters and running again.", + ) + + def _next_coordinate(self, pos1, pos2=None): + if pos2 is None: + phi = np.random.uniform(0, 2 * np.pi) + theta = np.random.uniform(0, np.pi) + next_pos = np.array( + [ + self.bond_length * np.sin(theta) * np.cos(phi), + self.bond_length * np.sin(theta) * np.sin(phi), + self.bond_length * np.cos(theta), + ] + ) + else: # Get the last bond vector, use angle range with last 2 coords. + v1 = pos2 - pos1 + v1_norm = v1 / np.linalg.norm(v1) + theta = np.random.uniform(self.min_angle, self.max_angle) + r = np.random.rand(3) - 0.5 + r_perp = r - np.dot(r, v1_norm) * v1_norm + r_perp_norm = r_perp / np.linalg.norm(r_perp) + v2 = np.cos(theta) * v1_norm + np.sin(theta) * r_perp_norm + next_pos = v2 * self.bond_length + + return pos1 + next_pos + + def _check_path(self): + """Use neighbor_list to check for pairs within a distance smaller than the radius""" + # Grow box size as number of steps grows + box_length = self.count * self.radius * 2.01 + # Only need neighbor list for accepted moves + current trial move + coordinates = self.coordinates[: self.count + 2] + nlist = self.neighbor_list( + coordinates=coordinates, + r_max=self.radius - self.tolerance, + box=[box_length, box_length, box_length], + ) + if len(nlist.distances) > 0: # Particle pairs found within the particle radius + return False + else: + return True + + +class Lamellae(Path): + def __init__(self): + super(Lamellae, self).__init__() diff --git a/mbuild/utils/geometry.py b/mbuild/utils/geometry.py index fad27db2d..ee037dfee 100644 --- a/mbuild/utils/geometry.py +++ b/mbuild/utils/geometry.py @@ -28,6 +28,47 @@ def calc_dihedral(point1, point2, point3, point4): return angle(x, y) +def bounding_box(xyz): + """Find the bounding box from a set of coordinates.""" + # case where only 1 particle exists + is_one_particle = False + if xyz.shape[0] == 1: + is_one_particle = True + + # are any columns all equalivalent values? + # an example of this would be a planar molecule + # example: all z values are 0.0 + # from: https://stackoverflow.com/a/14860884 + # steps: create mask array comparing first value in each column + # use np.all with axis=0 to do row columnar comparision + has_dimension = [True, True, True] + if not is_one_particle: + missing_dimensions = np.all( + np.isclose(xyz, xyz[0, :], atol=1e-2), + axis=0, + ) + for i, truthy in enumerate(missing_dimensions): + has_dimension[i] = not truthy + + if is_one_particle: + v1 = np.asarray([[1.0, 0.0, 0.0]]) + v2 = np.asarray([[0.0, 1.0, 0.0]]) + v3 = np.asarray([[0.0, 0.0, 1.0]]) + else: + maxs = xyz.max(axis=0) + mins = xyz.min(axis=0) + v1 = np.asarray((maxs[0] - mins[0], 0.0, 0.0)) + v2 = np.asarray((0.0, maxs[1] - mins[1], 0.0)) + v3 = np.asarray((0.0, 0.0, maxs[2] - mins[2])) + vecs = [v1, v2, v3] + + # handle any missing dimensions (planar molecules) + for i, dim in enumerate(has_dimension): + if not dim: + vecs[i][i] = 0.1 + return vecs + + def coord_shift(xyz, box): """Ensure that coordinates are -L/2, L/2. From b406f1876a8ff1989c18f776ba224aeb0b05072e Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 25 Mar 2025 23:48:55 -0600 Subject: [PATCH 018/123] add build_from_path method --- mbuild/polymer.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 0f4522cd2..d5d79a90d 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -281,6 +281,16 @@ def build(self, n, sequence="A", add_hydrogens=True): if id(port) not in port_ids: self.remove(port) + def build_from_path( + self, path, sequence="A", add_hydrogens=True, energy_minimize=True + ): + self.build( + n=path.coordinates.shape[0], sequence=sequence, add_hydrogens=add_hydrogens + ) + self.set_monomer_positions( + coordinates=path.coordinates, energy_minimize=energy_minimize + ) + def build_random_configuration( self, n, From 307671d667c2dde43f0f27e2265d603ee35ca569 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 26 Mar 2025 00:27:41 -0600 Subject: [PATCH 019/123] update doc strings --- mbuild/path.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mbuild/path.py b/mbuild/path.py index 3fca7bcad..74f6fee14 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -108,7 +108,7 @@ def __init__( def generate(self): np.random.seed(self.seed) - # First move is always accepted + # With fixed bond lengths, the first move is always accepted self.coordinates[1] = self._next_coordinate(pos1=self.coordinates[0]) self.bonds.append([0, 1]) self.count += 1 # We already have 1 accepted move From ab17e4a9492a94edd69b23730c44778d93c8ff45 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 26 Mar 2025 14:39:31 -0600 Subject: [PATCH 020/123] add notes to doc strings --- mbuild/path.py | 78 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 58 insertions(+), 20 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index 74f6fee14..85c4cdd6f 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -12,13 +12,13 @@ class Path(ABC): def __init__(self, N=None, max_attempts=10000): self.max_attempts = max_attempts - self.attempts = 0 self.compound = None self.N = N - self.nlist = None + self.attempts = 0 if N: self.coordinates = np.zeros((N, 3)) # Not every path will know N ahead of time (Lamellae) + # Do we have a different data structure for these? Template? else: self.coordinates = [] # Generate dict values now? @@ -29,23 +29,63 @@ def __init__(self, N=None, max_attempts=10000): A path is basically a bond graph with coordinates/positions assigned to the nodes. This is kind of what Compound is already. - The interesting part is building up/creating the path. + The interesting and challenging part is building up/creating the path. This follows an algorithm to generate next coordinates. - Any useful path generation algorithm will include - a rejection/acception step. Basically end up with - monte carlo. + Any random path generation algorithm will include + a rejection/acception step. We basically end up with + monte carlo. Some path algorithms won't be random (lamellae) - Is Path basically going to be a simple Monte carlo - class that others can inherit from? + Is Path essentially going to be a simple Monte carlo-ish + class that others can inherit from then implement their own approach? Classes that inherit from path will have their own - verions of next_coordinate, check path, etc.. - We can define abstract methods here. + verions of next_coordinate(), check_path(), etc.. + We can define abstract methods for these in Path. + We can put universally useful methods in Path as well. + + Some paths (lamellar structures) would kind of just do + everything in generate() without having to use + next_coordinate() or check_path(). These would still need to be + defined, but just left empty and/or always return True in the case of check_path. + Maybe that means these kinds of "paths" need a different data structure? + + Do we just have RandomPath and DeterministicPath? + + RandomPath ideas: + - Random walk (tons of possibilities here) + - Branching + - Multiple random walks + - + + DeterministicPath ideas: + - Lamellar layers + - Protein sub structure """ @abstractmethod def generate(self): + """Abstract class for running a Path generation algorithm + + This method should: + ----------------- + - Set initial conditions + - Implement Path generation steps by calling _next_coordinate() and _check_path() + - Update bonding info depending on specific path approach + - Ex) Random walk will always bond consecutive beads together + - Handle cases of next coordiante acceptance + - Handle cases of next coordinate rejection + """ + pass + + @abstractmethod + def _next_coordinate(self): + """Algorithm to generate the next coordinate in the path""" + pass + + @abstractmethod + def _check_path(self): + """Algorithm to accept/reject trial move of the current path""" pass def neighbor_list(self, r_max, coordinates=None, box=None): @@ -62,26 +102,24 @@ def neighbor_list(self, r_max, coordinates=None, box=None): nlist = aq_query.toNeighborList() return nlist - def to_compound(self): + def to_compound(self, bead_name="Bead", bead_mass=1): + """Visualize a path as an mBuild Compound""" compound = Compound() for xyz in self.coordinates: - compound.add(Compound(name="Bead", pos=xyz)) + compound.add(Compound(name=bead_name, mass=bead_mass, pos=xyz)) for bond_group in self.bonds: compound.add_bond([compound[bond_group[0]], compound[bond_group[1]]]) return compound def apply_mapping(self): + """Mapping other compounds onto a Path's coordinates""" pass def _path_history(self): - pass - - @abstractmethod - def _next_coordinate(self): - pass - - @abstractmethod - def _check_path(self): + """Maybe this is a method that can be used optionally. + We could add a save_history parameter to __init__. + Depending on the approach, saving histories might add additionally computation time and resources. + """ pass From 8873069ff4843cbc583befc70a6521a01b11aa14 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 28 Mar 2025 14:23:30 -0600 Subject: [PATCH 021/123] begin adding lamellar Path --- mbuild/path.py | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/mbuild/path.py b/mbuild/path.py index 85c4cdd6f..02b8598f0 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -1,5 +1,6 @@ """Molecular paths and templates""" +import math from abc import ABC, abstractmethod import freud @@ -211,5 +212,50 @@ def _check_path(self): class Lamellae(Path): - def __init__(self): + def __init__(self, num_layers, layer_separation, layer_length, bond_length): + self.num_layers = num_layers + self.layer_separation = layer_separation + self.layer_length = layer_length + self.bond_length = bond_length super(Lamellae, self).__init__() + + def generate(self): + layer_spacing = np.arange(0, self.layer_length, self.bond_length) + # Info for generating coords of the curves between layers + r = self.layer_separation / 2 + arc_length = r * np.pi + arc_num_points = math.floor(arc_length / self.bond_length) + arc_angle = np.pi / (arc_num_points + 1) # incremental angle + arc_angles = np.linspace(arc_angle, np.pi, arc_num_points, endpoint=False) + for i in range(self.num_layers): + if i % 2 == 0: # Even layer; build from left to right + layer = [ + np.array([self.layer_separation * i, y, 0]) for y in layer_spacing + ] + # Mid-point between this and next layer; use to get curve coords. + origin = layer[-1] + np.array([r, 0, 0]) + arc = [ + origin + np.array([-np.cos(theta), np.sin(theta), 0]) * r + for theta in arc_angles + ] + else: # Odd layer; build from right to left + layer = [ + np.array([self.layer_separation * i, y, 0]) + for y in layer_spacing[::-1] + ] + # Mid-point between this and next layer; use to get curve coords. + origin = layer[-1] + np.array([r, 0, 0]) + arc = [ + origin + np.array([-np.cos(theta), -np.sin(theta), 0]) * r + for theta in arc_angles + ] + if i != self.num_layers - 1: + self.coordinates.extend(layer + arc) + else: + self.coordinates.extend(layer) + + def _next_coordinate(self): + pass + + def _check_path(self): + return True From d40355b6cee99cfc8e6a76e7bab980036caee865 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 10 Apr 2025 14:14:59 -0600 Subject: [PATCH 022/123] add Lamellar path/template class --- mbuild/compound.py | 2 +- mbuild/path.py | 25 ++++++++++++++----------- mbuild/polymer.py | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 1950ad447..48f471acc 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -2018,7 +2018,7 @@ def energy_minimize( The number of optimization iterations forcefield : str, optional, default='UFF' The generic force field to apply to the Compound for minimization. - Valid options are 'MMFF94', 'MMFF94s', ''UFF', 'GAFF', 'Ghemical'. + Valid options are 'MMFF94', 'MMFF94s', 'UFF', 'GAFF', 'Ghemical'. Please refer to the `Open Babel documentation `_ when considering your choice of force field. diff --git a/mbuild/path.py b/mbuild/path.py index 02b8598f0..4baa9a68d 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -11,19 +11,14 @@ class Path(ABC): - def __init__(self, N=None, max_attempts=10000): - self.max_attempts = max_attempts - self.compound = None + def __init__(self, N=None): self.N = N - self.attempts = 0 if N: self.coordinates = np.zeros((N, 3)) # Not every path will know N ahead of time (Lamellae) - # Do we have a different data structure for these? Template? + # Do we make a different class and data structures for these? Template? else: self.coordinates = [] - # Generate dict values now? - # Entry for each node, initialize with empty lists self.bonds = [] """ @@ -34,7 +29,7 @@ def __init__(self, N=None, max_attempts=10000): This follows an algorithm to generate next coordinates. Any random path generation algorithm will include a rejection/acception step. We basically end up with - monte carlo. Some path algorithms won't be random (lamellae) + Monte Carlo. Some path algorithms won't be random (lamellae) Is Path essentially going to be a simple Monte carlo-ish class that others can inherit from then implement their own approach? @@ -55,12 +50,16 @@ class that others can inherit from then implement their own approach? RandomPath ideas: - Random walk (tons of possibilities here) - Branching - - Multiple random walks + - Multiple self-avoiding random walks - DeterministicPath ideas: - Lamellar layers - - Protein sub structure + - + + Some combination of these? + - Lamellar + random walk to generate semi-crystalline like structures? + - """ @@ -142,8 +141,10 @@ def __init__( self.max_angle = max_angle self.seed = seed self.tolerance = tolerance + self.max_attempts = max_attempts + self.attempts = 0 self.count = 0 - super(HardSphereRandomWalk, self).__init__(N=N, max_attempts=max_attempts) + super(HardSphereRandomWalk, self).__init__(N=N) def generate(self): np.random.seed(self.seed) @@ -253,6 +254,8 @@ def generate(self): self.coordinates.extend(layer + arc) else: self.coordinates.extend(layer) + for i in range(len(self.coordinates) - 1): + self.bonds.append([i, i + 1]) def _next_coordinate(self): pass diff --git a/mbuild/polymer.py b/mbuild/polymer.py index d5d79a90d..0dec4d0e9 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -285,7 +285,7 @@ def build_from_path( self, path, sequence="A", add_hydrogens=True, energy_minimize=True ): self.build( - n=path.coordinates.shape[0], sequence=sequence, add_hydrogens=add_hydrogens + n=len(path.coordinates), sequence=sequence, add_hydrogens=add_hydrogens ) self.set_monomer_positions( coordinates=path.coordinates, energy_minimize=energy_minimize From 8790c38357e818c0ed55ce2a912cb4c84ae919d3 Mon Sep 17 00:00:00 2001 From: Chris Jones <50423140+chrisjonesBSU@users.noreply.github.com> Date: Thu, 29 May 2025 03:01:26 -0600 Subject: [PATCH 023/123] remove build random method --- mbuild/polymer.py | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 0dec4d0e9..020d294d7 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -291,33 +291,6 @@ def build_from_path( coordinates=path.coordinates, energy_minimize=energy_minimize ) - def build_random_configuration( - self, - n, - sequence="A", - min_angle=np.pi / 2, - max_angle=np.pi, - radius=None, - seed=42, - energy_minimize=True, - add_hydrogens=True, - ): - # Build initial polymer chain - self.build(n=n, sequence=sequence, add_hydrogens=add_hydrogens) - # Get new coordinates - avg_bond_L = np.mean([L for L in self.backbone_bond_lengths()]) - if not radius: - radius = avg_bond_L * 1.2 - coords = random_walk( - N=len(self.children), - min_angle=min_angle, - max_angle=max_angle, - bond_L=avg_bond_L, - radius=radius, - seed=seed, - ) - self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def build_lamellae( self, num_layers, From 6a84dd9894e4cc9b1cbe3b5ef93f2884e86acaf6 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 6 Jun 2025 15:40:02 +0100 Subject: [PATCH 024/123] remove if statement from random walk initial step --- mbuild/path.py | 65 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index 4baa9a68d..e6d118e87 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -149,7 +149,17 @@ def __init__( def generate(self): np.random.seed(self.seed) # With fixed bond lengths, the first move is always accepted - self.coordinates[1] = self._next_coordinate(pos1=self.coordinates[0]) + phi = np.random.uniform(0, 2 * np.pi) + theta = np.random.uniform(0, np.pi) + next_pos = np.array( + [ + self.bond_length * np.sin(theta) * np.cos(phi), + self.bond_length * np.sin(theta) * np.sin(phi), + self.bond_length * np.cos(theta), + ] + ) + self.coordinates[1] = next_pos + #self.coordinates[1] = _next_coordinate(pos1=self.coordinates[0]) self.bonds.append([0, 1]) self.count += 1 # We already have 1 accepted move while self.count < self.N - 1: @@ -173,25 +183,25 @@ def generate(self): ) def _next_coordinate(self, pos1, pos2=None): - if pos2 is None: - phi = np.random.uniform(0, 2 * np.pi) - theta = np.random.uniform(0, np.pi) - next_pos = np.array( - [ - self.bond_length * np.sin(theta) * np.cos(phi), - self.bond_length * np.sin(theta) * np.sin(phi), - self.bond_length * np.cos(theta), - ] - ) - else: # Get the last bond vector, use angle range with last 2 coords. - v1 = pos2 - pos1 - v1_norm = v1 / np.linalg.norm(v1) - theta = np.random.uniform(self.min_angle, self.max_angle) - r = np.random.rand(3) - 0.5 - r_perp = r - np.dot(r, v1_norm) * v1_norm - r_perp_norm = r_perp / np.linalg.norm(r_perp) - v2 = np.cos(theta) * v1_norm + np.sin(theta) * r_perp_norm - next_pos = v2 * self.bond_length + #if pos2 is None: + # phi = np.random.uniform(0, 2 * np.pi) + # theta = np.random.uniform(0, np.pi) + # next_pos = np.array( + # [ + # self.bond_length * np.sin(theta) * np.cos(phi), + # self.bond_length * np.sin(theta) * np.sin(phi), + # self.bond_length * np.cos(theta), + # ] + # ) + #else: # Get the last bond vector, use angle range with last 2 coords. + v1 = pos2 - pos1 + v1_norm = v1 / np.linalg.norm(v1) + theta = np.random.uniform(self.min_angle, self.max_angle) + r = np.random.rand(3) - 0.5 + r_perp = r - np.dot(r, v1_norm) * v1_norm + r_perp_norm = r_perp / np.linalg.norm(r_perp) + v2 = np.cos(theta) * v1_norm + np.sin(theta) * r_perp_norm + next_pos = v2 * self.bond_length return pos1 + next_pos @@ -261,4 +271,17 @@ def _next_coordinate(self): pass def _check_path(self): - return True + """Use neighbor_list to check for pairs within a distance smaller than the radius""" + # Grow box size as number of steps grows + box_length = self.count * self.radius * 2.01 + # Only need neighbor list for accepted moves + current trial move + coordinates = self.coordinates[: self.count + 2] + nlist = self.neighbor_list( + coordinates=coordinates, + r_max=self.radius - self.tolerance, + box=[box_length, box_length, box_length], + ) + if len(nlist.distances) > 0: # Particle pairs found within the particle radius + return False + else: + return True From e8202d848383a11ca1b83faa204846fb9b049888 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 17 Jun 2025 11:28:11 +0100 Subject: [PATCH 025/123] Add update_bond_graph method to Compound --- mbuild/compound.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index f5d2ff7d6..651d6d4b1 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -240,6 +240,26 @@ def successors(self): for subpart in part.successors(): yield subpart + def update_bond_graph(self, new_graph): + """Manually set the compound's complete bond graph. + + Parameters: + ----------- + new_graph : networkx.Graph, required + A networkx.Graph containing information about + nodes and edges. + """ + graph = nx.Graph() + # node is int corresponding to particle index + for node in new_graph.nodes: + graph.add_node(node_for_adding=self[node]) + for edge in new_graph.edges: + graph.add_edge( + u_of_edge=self[edge[0]], + v_of_edge=self[edge[1]], + ) + self.bond_graph = graph + @property def n_particles(self): """Return the number of Particles in the Compound. @@ -637,7 +657,7 @@ def add( if self.root.bond_graph.has_node(self): self.root.bond_graph.remove_node(self) # compose the bond graph of all the children with the root - self.root.bond_graph = nx.compose( + self.root._bond_graph = nx.compose( self.root.bond_graph, children_bond_graph ) for i, child in enumerate(compound_list): @@ -3396,10 +3416,12 @@ def _clone_bonds(self, clone_of=None): newone.bond_graph.add_node(clone_of[particle]) for c1, c2, data in self.bonds(return_bond_order=True): try: + try: + bond_order = data["bond_order"] + except KeyError: + bond_order = "unspecified" # bond order is added to the data dictionary as 'bo' - newone.add_bond( - (clone_of[c1], clone_of[c2]), bond_order=data["bond_order"] - ) + newone.add_bond((clone_of[c1], clone_of[c2]), bond_order=bond_order) except KeyError: raise MBuildError( "Cloning failed. Compound contains bonds to " From 4d7d8577cb432ba8d9b5aca5981c2b09c1f5b88d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 17 Jun 2025 14:29:21 +0100 Subject: [PATCH 026/123] remove conformations.py, add cls method to Path, make bond_graph a parameter --- mbuild/conformations.py | 148 ---------------------------------------- mbuild/path.py | 125 ++++++++++++++++----------------- mbuild/polymer.py | 21 ------ 3 files changed, 59 insertions(+), 235 deletions(-) delete mode 100644 mbuild/conformations.py diff --git a/mbuild/conformations.py b/mbuild/conformations.py deleted file mode 100644 index 0dfb549d7..000000000 --- a/mbuild/conformations.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Simple random walk algorithm for generating polymer chains.""" - -import math - -import numpy as np - - -def lamellae(num_layers, layer_separation, layer_length, bond_L): - """Generate monomer coordinates of a lamellar structure. - - Parameters - ---------- - num_layers : int, required - The number of parallel layers in the structure. - layer_separation : float, (nm), required. - The distance, in nanometers, between parallel layers. - layer_length : float, (nm), required. - The length, in nanometers, of each layer. - bond_L : float, (nm), required. - The monomer-monomer bond length of the backbone. - """ - layer_spacing = np.arange(0, layer_length, bond_L) - # Info for generating coords of the curves between layers - r = layer_separation / 2 - arc_length = r * np.pi - arc_num_points = math.floor(arc_length / bond_L) - arc_angle = np.pi / (arc_num_points + 1) # incremental angle - arc_angles = np.linspace(arc_angle, np.pi, arc_num_points, endpoint=False) - coordinates = [] - for i in range(num_layers): - if i % 2 == 0: # Even layer; build from left to right - layer = [np.array([layer_separation * i, y, 0]) for y in layer_spacing] - # Mid-point between this and next layer; use to get curve coords. - origin = layer[-1] + np.array([r, 0, 0]) - arc = [ - origin + np.array([-np.cos(theta), np.sin(theta), 0]) * r - for theta in arc_angles - ] - else: # Odd layer; build from right to left - layer = [ - np.array([layer_separation * i, y, 0]) for y in layer_spacing[::-1] - ] - # Mid-point between this and next layer; use to get curve coords. - origin = layer[-1] + np.array([r, 0, 0]) - arc = [ - origin + np.array([-np.cos(theta), -np.sin(theta), 0]) * r - for theta in arc_angles - ] - if i != num_layers - 1: - coordinates.extend(layer + arc) - else: - coordinates.extend(layer) - return coordinates - - -def random_walk(N, bond_L, radius, min_angle, max_angle, max_tries=1000, seed=24): - """Generate chain coordinates resulting from a simple self-avoiding random walk. - - Parameters - ---------- - N : int, required - The number of particles in the random walk. - bond_L : float, nm, required - The fixed bond distance between consecutive sites. - radius : float, nm, required - Defines the monomer radius used when checking for overlapping sites. - min_angle : float, radians, required - The minimum allowed angle between 3 consecutive sites. - max_angle : float, radians, required - The maximum allowed angle between 3 consecutive sites. - max_tries : int, default 1000 - The maximum number of attemps to complete the random walk. - seed : int, default 24 - Random seed used during random walk. - - Returns - ------- - coordinates : np.ndarray, shape=(N, 3) - Final set of coordinates from random walk. - - """ - np.random.seed(seed) - # First coord is always [0,0,0], next pos is always accepted. - coordinates = np.zeros((N, 3)) - coordinates[1] = _next_coordinate(pos1=coordinates[0], bond_L=bond_L) - tries = 0 - count = 1 # Start at 1; we already have 2 accepted moves - while count < N - 1: - new_xyz = _next_coordinate( - pos1=coordinates[count], - pos2=coordinates[count - 1], - min_angle=min_angle, - max_angle=max_angle, - bond_L=bond_L, - ) - coordinates[count + 1] = new_xyz - - if _check_system( - system_coordinates=coordinates, radius=radius, count=count + 1 - ): - count += 1 - tries += 1 - else: # Next step failed. Set next coordinate back to (0,0,0). - coordinates[count + 1] = np.zeros(3) - tries += 1 - if tries == max_tries and count < N: - raise RuntimeError( - "The maximum number attempts allowed have passed, and only ", - f"{count} sucsessful attempts were completed.", - "Try changing the parameters and running again.", - ) - - return coordinates - - -def _next_coordinate(bond_L, pos1, pos2=None, min_angle=None, max_angle=None): - if pos2 is None: - phi = np.random.uniform(0, 2 * np.pi) - theta = np.random.uniform(0, np.pi) - next_pos = np.array( - [ - bond_L * np.sin(theta) * np.cos(phi), - bond_L * np.sin(theta) * np.sin(phi), - bond_L * np.cos(theta), - ] - ) - else: # Get the last bond vector, use angle range with last 2 coords. - v1 = pos2 - pos1 - v1_norm = v1 / np.linalg.norm(v1) - theta = np.random.uniform(min_angle, max_angle) - r = np.random.rand(3) - 0.5 # Random vector - r_perp = r - np.dot(r, v1_norm) * v1_norm - r_perp_norm = r_perp / np.linalg.norm(r_perp) - v2 = np.cos(theta) * v1_norm + np.sin(theta) * r_perp_norm - next_pos = v2 * bond_L - - return pos1 + next_pos - - -def _check_system(system_coordinates, radius, count): - # Count is the last particle, iterate through all others - # Skip the particle bonded to the current one - current_xyz = system_coordinates[count] - for xyz in system_coordinates[: count - 1]: - d = np.linalg.norm(xyz - current_xyz) - if d < radius: - return False - return True diff --git a/mbuild/path.py b/mbuild/path.py index e6d118e87..ed7f690dc 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -1,7 +1,7 @@ """Molecular paths and templates""" import math -from abc import ABC, abstractmethod +from abc import abstractmethod import freud import numpy as np @@ -9,59 +9,66 @@ from mbuild import Compound from mbuild.utils.geometry import bounding_box +""" +A path is basically a bond graph with coordinates/positions +assigned to the nodes. This is kind of what Compound is already. -class Path(ABC): - def __init__(self, N=None): - self.N = N - if N: - self.coordinates = np.zeros((N, 3)) - # Not every path will know N ahead of time (Lamellae) - # Do we make a different class and data structures for these? Template? - else: - self.coordinates = [] - self.bonds = [] +The interesting and challenging part is building up/creating the path. +This follows an algorithm to generate next coordinates. +Any random path generation algorithm will include +a rejection/acception step. We basically end up with +Monte Carlo. Some path algorithms won't be random (lamellae) - """ - A path is basically a bond graph with coordinates/positions - assigned to the nodes. This is kind of what Compound is already. +Is Path essentially going to be a simple Monte carlo-ish +class that others can inherit from then implement their own approach? - The interesting and challenging part is building up/creating the path. - This follows an algorithm to generate next coordinates. - Any random path generation algorithm will include - a rejection/acception step. We basically end up with - Monte Carlo. Some path algorithms won't be random (lamellae) +Classes that inherit from path will have their own +verions of next_coordinate(), check_path(), etc.. +We can define abstract methods for these in Path. +We can put universally useful methods in Path as well (e.g., n_list(), to_compound(), etc..). - Is Path essentially going to be a simple Monte carlo-ish - class that others can inherit from then implement their own approach? +Some paths (lamellar structures) would kind of just do +everything in generate() without having to use +next_coordinate() or check_path(). These would still need to be +defined, but just left empty and/or always return True in the case of check_path. +Maybe that means these kinds of "paths" need a different data structure? - Classes that inherit from path will have their own - verions of next_coordinate(), check_path(), etc.. - We can define abstract methods for these in Path. - We can put universally useful methods in Path as well. +Do we just have RandomPath and DeterministicPath? - Some paths (lamellar structures) would kind of just do - everything in generate() without having to use - next_coordinate() or check_path(). These would still need to be - defined, but just left empty and/or always return True in the case of check_path. - Maybe that means these kinds of "paths" need a different data structure? +RandomPath ideas: +- Random walk (tons of possibilities here) +- Branching +- Multiple self-avoiding random walks - Do we just have RandomPath and DeterministicPath? +DeterministicPath ideas: +- Lamellar layers +- DNA strands - RandomPath ideas: - - Random walk (tons of possibilities here) - - Branching - - Multiple self-avoiding random walks - - +Some combination of these? +- Lamellar + random walk to generate semi-crystalline like structures? +- +""" - DeterministicPath ideas: - - Lamellar layers - - - Some combination of these? - - Lamellar + random walk to generate semi-crystalline like structures? - - +class Path: + def __init__(self, N=None, bond_graph=None, coordinates=None): + self.bond_graph = bond_graph + if N and coordinates is None: + self.N = N + self.coordinates = np.zeros((N, 3)) + elif coordinates is not None and not N: + self.N = len(coordinates) + self.coordinates = coordinates + elif coordinates is not None and N: + self.N = N + self.coordinates = [] + else: + raise ValueError("Specify either one of N and coordinates, or neither") + self.generate() - """ + @classmethod + def from_coordinates(cls, coordinates, bond_graph=None): + return cls(coordinates=coordinates, bond_graph=bond_graph) @abstractmethod def generate(self): @@ -107,8 +114,8 @@ def to_compound(self, bead_name="Bead", bead_mass=1): compound = Compound() for xyz in self.coordinates: compound.add(Compound(name=bead_name, mass=bead_mass, pos=xyz)) - for bond_group in self.bonds: - compound.add_bond([compound[bond_group[0]], compound[bond_group[1]]]) + if self.bond_graph: + compound.update_bond_graph(self.bond_graph) return compound def apply_mapping(self): @@ -118,7 +125,7 @@ def apply_mapping(self): def _path_history(self): """Maybe this is a method that can be used optionally. We could add a save_history parameter to __init__. - Depending on the approach, saving histories might add additionally computation time and resources. + Depending on the approach, saving histories might add additional computation time and resources. """ pass @@ -134,6 +141,7 @@ def __init__( max_attempts, seed, tolerance=1e-5, + bond_graph=None, ): self.bond_length = bond_length self.radius = radius @@ -144,7 +152,7 @@ def __init__( self.max_attempts = max_attempts self.attempts = 0 self.count = 0 - super(HardSphereRandomWalk, self).__init__(N=N) + super(HardSphereRandomWalk, self).__init__(N=N, bond_graph=bond_graph) def generate(self): np.random.seed(self.seed) @@ -159,8 +167,7 @@ def generate(self): ] ) self.coordinates[1] = next_pos - #self.coordinates[1] = _next_coordinate(pos1=self.coordinates[0]) - self.bonds.append([0, 1]) + # self.coordinates[1] = _next_coordinate(pos1=self.coordinates[0]) self.count += 1 # We already have 1 accepted move while self.count < self.N - 1: new_xyz = self._next_coordinate( @@ -169,7 +176,6 @@ def generate(self): ) self.coordinates[self.count + 1] = new_xyz if self._check_path(): - self.bonds.append((self.count, self.count + 1)) self.count += 1 self.attempts += 1 else: @@ -183,7 +189,7 @@ def generate(self): ) def _next_coordinate(self, pos1, pos2=None): - #if pos2 is None: + # if pos2 is None: # phi = np.random.uniform(0, 2 * np.pi) # theta = np.random.uniform(0, np.pi) # next_pos = np.array( @@ -193,7 +199,7 @@ def _next_coordinate(self, pos1, pos2=None): # self.bond_length * np.cos(theta), # ] # ) - #else: # Get the last bond vector, use angle range with last 2 coords. + # else: # Get the last bond vector, use angle range with last 2 coords. v1 = pos2 - pos1 v1_norm = v1 / np.linalg.norm(v1) theta = np.random.uniform(self.min_angle, self.max_angle) @@ -271,17 +277,4 @@ def _next_coordinate(self): pass def _check_path(self): - """Use neighbor_list to check for pairs within a distance smaller than the radius""" - # Grow box size as number of steps grows - box_length = self.count * self.radius * 2.01 - # Only need neighbor list for accepted moves + current trial move - coordinates = self.coordinates[: self.count + 2] - nlist = self.neighbor_list( - coordinates=coordinates, - r_max=self.radius - self.tolerance, - box=[box_length, box_length, box_length], - ) - if len(nlist.distances) > 0: # Particle pairs found within the particle radius - return False - else: - return True + pass diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 020d294d7..6a9572833 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -6,7 +6,6 @@ from mbuild import clone from mbuild.compound import Compound -from mbuild.conformations import lamellae, random_walk from mbuild.coordinate_transform import ( force_overlap, x_axis_transform, @@ -291,26 +290,6 @@ def build_from_path( coordinates=path.coordinates, energy_minimize=energy_minimize ) - def build_lamellae( - self, - num_layers, - layer_length, - layer_separation, - bond_L, - sequence="A", - energy_minimize=True, - add_hydrogens=True, - ): - # Get lamellar coords first to determine n monomers - coords = lamellae( - num_layers=num_layers, - layer_length=layer_length, - bond_L=bond_L, - layer_separation=layer_separation, - ) - self.build(n=len(coords), sequence=sequence, add_hydrogens=add_hydrogens) - self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def add_monomer( self, compound, From 392ef5c6e46e7545a4d806a8dcb20eb5a8c7763d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 18 Jun 2025 17:05:18 +0100 Subject: [PATCH 027/123] add some basic tests --- mbuild/compound.py | 29 ++++++++++++++++++++++++----- mbuild/tests/test_compound.py | 21 +++++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 651d6d4b1..38ed3916c 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -240,23 +240,42 @@ def successors(self): for subpart in part.successors(): yield subpart - def update_bond_graph(self, new_graph): + def set_bond_graph(self, new_graph): """Manually set the compound's complete bond graph. Parameters: ----------- new_graph : networkx.Graph, required A networkx.Graph containing information about - nodes and edges. + nodes and edges that is used to construct a + new mbuild.bond_graph.BondGraph(). + + Notes: + ------ + The nodes of the new graph should be ints that are mapped to + the indices of the particles in the compound. + + mbuild bond graphs accept `bond_order` arguments. If the + `bond_order` is present in `new_graph`, it will be used, + otherwise `bond_under` is set to 'unspecified'. """ - graph = nx.Graph() + if new_graph.number_of_nodes() != self.n_particles: + raise ValueError( + f"new_graph contains {new_graph.number_of_nodes()}", + f" but the Compound only contains {self.n_particles}", + "The graph must contain one node per particle.", + ) + graph = BondGraph() # node is int corresponding to particle index for node in new_graph.nodes: graph.add_node(node_for_adding=self[node]) for edge in new_graph.edges: + if "bond_order" in edge: + bond_order = edge["bond_order"] + else: + bond_order = "unspecified" graph.add_edge( - u_of_edge=self[edge[0]], - v_of_edge=self[edge[1]], + u_of_edge=self[edge[0]], v_of_edge=self[edge[1]], bond_order=bond_order ) self.bond_graph = graph diff --git a/mbuild/tests/test_compound.py b/mbuild/tests/test_compound.py index 8438725f2..7fd881c0c 100644 --- a/mbuild/tests/test_compound.py +++ b/mbuild/tests/test_compound.py @@ -1,6 +1,7 @@ import os import sys +import networkx as nx import numpy as np import parmed as pmd import pytest @@ -921,6 +922,26 @@ def test_remove_subcompound(self, ethane): assert ethane.n_direct_bonds == 0 assert len(ethane.children) == 0 + def test_set_bond_graph(self): + compound = Compound() + for i in range(5): + compound.add(Compound(pos=[0.2 * i, 0, 0])) + assert compound.n_bonds == 0 + compound.set_bond_graph(new_graph=nx.path_graph(5)) + assert compound.bond_graph.number_of_nodes() == 5 + assert compound.n_bonds == compound.bond_graph.number_of_edges() == 4 + for bond in compound.bonds(return_bond_order=True): + assert bond[2]["bond_order"] == "unspecified" + + def test_set_bond_graph_mismatch(self): + compound = Compound() + for i in range(5): + compound.add(Compound(pos=[0.2 * i, 0, 0])) + with pytest.raises(ValueError): + compound.set_bond_graph(new_graph=nx.path_graph(6)) + with pytest.raises(ValueError): + compound.set_bond_graph(new_graph=nx.path_graph(4)) + def test_remove_no_bond_graph(self): compound = Compound() particle = Compound(name="C", pos=[0, 0, 0]) From c94be286ead2e602e11364d9e5457ee27b996d64 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 18 Jun 2025 23:18:56 +0100 Subject: [PATCH 028/123] start framework for hoomd emin methods --- mbuild/simulation.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 8b10fd361..2ade0f36a 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -4,15 +4,36 @@ import tempfile from warnings import warn +import gmso import numpy as np from ele.element import element_from_name, element_from_symbol from ele.exceptions import ElementError +from gmso.parameterization import apply from mbuild import Compound from mbuild.exceptions import MBuildError from mbuild.utils.io import import_ +def remove_overlaps(compound, forcefield, steps, max_displacement=1e-3, **kwargs): + """Run a short HOOMD-Blue simulation to remove overlapping particles. + This uses hoomd.md.methods.DisplacementCapped.""" + pass + top = compound.to_gmso() + top.identify_connections() + apply(top=top, forcefield=forcefield, **kwargs) + snap, refs = gmso.external.to_gsd_snapshot(top=top, autoscale=False) + hoomd_ff, refs = gmso.external.to_hoomd_forcefield(top=top, r_cut=1.2) + + +def _run_hoomd_simulation(snapshot, forces, method): + # Set up common hoomd stuff here + + # Set up method specific stuff here + if method == "displacement_capped": + pass + + def energy_minimize( compound, forcefield="UFF", From 6c8eda986359d37ffc84e5384990702ea84c2088 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 19 Jun 2025 10:43:36 +0100 Subject: [PATCH 029/123] more work on hoomd sim methods --- mbuild/simulation.py | 52 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 2ade0f36a..f14779a69 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -5,6 +5,7 @@ from warnings import warn import gmso +import hoomd import numpy as np from ele.element import element_from_name, element_from_symbol from ele.exceptions import ElementError @@ -15,23 +16,58 @@ from mbuild.utils.io import import_ -def remove_overlaps(compound, forcefield, steps, max_displacement=1e-3, **kwargs): +def remove_overlaps( + compound, forcefield, steps, max_displacement=1e-3, run_on_gpu=True, **kwargs +): """Run a short HOOMD-Blue simulation to remove overlapping particles. - This uses hoomd.md.methods.DisplacementCapped.""" - pass + This uses hoomd.md.methods.DisplacementCapped. + + Parameters: + ----------- + compound : mbuild.compound.Compound; required + The compound to perform the displacement capped simulation with. + forcefield : foyer.focefield.ForceField or gmso.core.Forcefield; required + The forcefield used for the simulation + steps : int; required + The number of simulation steps to run + max_displacement : float, default 1e-3 + The value of the maximum displacement (nm) + run_on_gpu : bool, default True + If true, attempts to run HOOMD-Blue on a GPU. + If a GPU device isn't found, then it will run on the CPU + """ top = compound.to_gmso() top.identify_connections() apply(top=top, forcefield=forcefield, **kwargs) snap, refs = gmso.external.to_gsd_snapshot(top=top, autoscale=False) hoomd_ff, refs = gmso.external.to_hoomd_forcefield(top=top, r_cut=1.2) + forces = hoomd_ff + sim = _create_hoomd_simulation(snapshot=snap, forces=forces, run_on_gpu=run_on_gpu) + method = hoomd.md.methods.DisplacementCapped( + filter=hoomd.filter.All(), + maximum_displacement=max_displacement, + ) + sim.operations.integrator.methods.append(method) + sim.run(steps) -def _run_hoomd_simulation(snapshot, forces, method): +def _create_hoomd_simulation(snapshot, forces, run_on_gpu): # Set up common hoomd stuff here - - # Set up method specific stuff here - if method == "displacement_capped": - pass + if run_on_gpu: + try: + device = hoomd.device.GPU() + print(f"GPU found, running on device {device.device}") + except RuntimeError: + print("GPU not found, running on CPU.") + device = hoomd.device.CPU() + else: + device = hoomd.device.CPU() + sim = hoomd.Simulation(device=device) + sim.create_state_from_snapshot(snapshot) + integrator = hoomd.md.Integrator(dt=0.0001) + integrator.forces = forces + sim.operations.integrator = integrator + return sim def energy_minimize( From 5ef87061a376c39e69526a97bd786787b8bbb732 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 19 Jun 2025 10:53:11 +0100 Subject: [PATCH 030/123] update doc strings --- mbuild/compound.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 38ed3916c..5498235fb 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -256,7 +256,7 @@ def set_bond_graph(self, new_graph): the indices of the particles in the compound. mbuild bond graphs accept `bond_order` arguments. If the - `bond_order` is present in `new_graph`, it will be used, + `bond_order` data is present in `new_graph`, it will be used, otherwise `bond_under` is set to 'unspecified'. """ if new_graph.number_of_nodes() != self.n_particles: From 08fd16db7d7e3a7d62375cc51eb648b2cadf11df Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 20 Jun 2025 14:48:15 +0100 Subject: [PATCH 031/123] Save progress with remove overlaps --- mbuild/simulation.py | 84 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 12 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index f14779a69..fce9b9222 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -16,11 +16,17 @@ from mbuild.utils.io import import_ -def remove_overlaps( - compound, forcefield, steps, max_displacement=1e-3, run_on_gpu=True, **kwargs +def remove_overlaps_fire( + compound, + forcefield, + A_initial=10, + bond_k_scale=100, + angle_k_scale=100, + fire_iteration_steps=5000, + final_relaxation_steps=5000, + run_on_gpu=True, ): """Run a short HOOMD-Blue simulation to remove overlapping particles. - This uses hoomd.md.methods.DisplacementCapped. Parameters: ----------- @@ -38,17 +44,71 @@ def remove_overlaps( """ top = compound.to_gmso() top.identify_connections() - apply(top=top, forcefield=forcefield, **kwargs) - snap, refs = gmso.external.to_gsd_snapshot(top=top, autoscale=False) - hoomd_ff, refs = gmso.external.to_hoomd_forcefield(top=top, r_cut=1.2) - forces = hoomd_ff - sim = _create_hoomd_simulation(snapshot=snap, forces=forces, run_on_gpu=run_on_gpu) - method = hoomd.md.methods.DisplacementCapped( + apply(top, forcefields=forcefield) + + forces, ref = gmso.external.to_hoomd_forcefield(top, r_cut=0.4) + snap, ref = gmso.external.to_gsd_snapshot(top) + forces = list(set().union(*forces.values())) + lj = [f for f in forces if isinstance(f, hoomd.md.pair.LJ)][0] + bond = [f for f in forces if isinstance(f, hoomd.md.bond.Harmonic)][0] + angle = [f for f in forces if isinstance(f, hoomd.md.angle.Harmonic)][0] + + # Make DPD force, pulling info from LJ + dpd = hoomd.md.pair.DPDConservative(nlist=lj.nlist) + for param in lj.params: + dpd.params[param] = dict(A=A_initial) + dpd.r_cut[param] = lj.r_cut[param] + dpd.r_cut[param] = lj.params[param]["sigma"] + + # Scale bond K and angle K + for param in bond.params: + bond.params[param]["k"] /= bond_k_scale + for param in angle.params: + angle.params[param]["k"] /= angle_k_scale + + ### HOOMD SIMULATION + sim = hoomd.Simulation(device=hoomd.device.auto_select()) + sim.create_state_from_snapshot(snap) + nvt = hoomd.md.methods.ConstantVolume(filter=hoomd.filter.All()) + fire = hoomd.md.minimize.FIRE( + dt=1e-4, + force_tol=1e-2, + angmom_tol=1e-2, + energy_tol=1e-3, + finc_dt=1.1, + fdec_dt=0.5, + alpha_start=0.2, + fdec_alpha=0.95, + min_steps_adapt=10, + min_steps_conv=20, + methods=[nvt], + ) + fire.forces = [dpd, bond, angle] + sim.operations.integrator = fire + gsd_writer = hoomd.write.GSD( + filename="traj.gsd", + trigger=hoomd.trigger.Periodic(10), + mode="wb", filter=hoomd.filter.All(), - maximum_displacement=max_displacement, ) - sim.operations.integrator.methods.append(method) - sim.run(steps) + sim.operations.writers.append(gsd_writer) + sim.run(5000, write_at_start=True) + # Scale bond K and angle K + for param in bond.params: + bond.params[param]["k"] *= bond_k_scale + for param in angle.params: + angle.params[param]["k"] *= angle_k_scale + sim.run(5000, write_at_start=False) + sim.operations.integrator.forces.remove(dpd) + sim.operations.integrator.forces.append(lj) + sim.run(10000, write_at_start=False) + with sim.state.cpu_local_snapshot as snap: + # freud_box = freud.Box.from_box(sim.state.box) + # unwrap_pos = freud_box.unwrap( + # snap.particles.position, + # snap.particles.image + # ) + compound.xyz = snap.particles.position def _create_hoomd_simulation(snapshot, forces, run_on_gpu): From 4c01b97f421f9a2d571ab143a87bfbe487040c1d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 20 Jun 2025 15:47:21 +0100 Subject: [PATCH 032/123] add more parameters --- mbuild/simulation.py | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index fce9b9222..837f8eff1 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -22,7 +22,12 @@ def remove_overlaps_fire( A_initial=10, bond_k_scale=100, angle_k_scale=100, + dt=1e-4, + force_tol=1e-2, + angmom_tol=1e-2, + energy_tol=1e-3, fire_iteration_steps=5000, + num_fire_iterations=2, final_relaxation_steps=5000, run_on_gpu=True, ): @@ -71,15 +76,15 @@ def remove_overlaps_fire( sim.create_state_from_snapshot(snap) nvt = hoomd.md.methods.ConstantVolume(filter=hoomd.filter.All()) fire = hoomd.md.minimize.FIRE( - dt=1e-4, - force_tol=1e-2, - angmom_tol=1e-2, - energy_tol=1e-3, + dt=dt, + force_tol=force_tol, + angmom_tol=angmom_tol, + energy_tol=energy_tol, finc_dt=1.1, fdec_dt=0.5, alpha_start=0.2, fdec_alpha=0.95, - min_steps_adapt=10, + min_steps_adapt=5, min_steps_conv=20, methods=[nvt], ) @@ -87,28 +92,27 @@ def remove_overlaps_fire( sim.operations.integrator = fire gsd_writer = hoomd.write.GSD( filename="traj.gsd", - trigger=hoomd.trigger.Periodic(10), + trigger=hoomd.trigger.Periodic(1000), mode="wb", filter=hoomd.filter.All(), ) sim.operations.writers.append(gsd_writer) - sim.run(5000, write_at_start=True) + for sim_num in range(num_fire_iterations): + sim.run(fire_iteration_steps, write_at_start=True) # Scale bond K and angle K for param in bond.params: bond.params[param]["k"] *= bond_k_scale for param in angle.params: angle.params[param]["k"] *= angle_k_scale - sim.run(5000, write_at_start=False) + sim.run(fire_iteration_steps, write_at_start=False) sim.operations.integrator.forces.remove(dpd) sim.operations.integrator.forces.append(lj) - sim.run(10000, write_at_start=False) + for sim_num in range(num_fire_iterations): + sim.run(fire_iteration_steps, write_at_start=True) with sim.state.cpu_local_snapshot as snap: - # freud_box = freud.Box.from_box(sim.state.box) - # unwrap_pos = freud_box.unwrap( - # snap.particles.position, - # snap.particles.image - # ) - compound.xyz = snap.particles.position + particles = snap.particles.rtag[:] + pos = snap.particles.position[particles] + compound.xyz = pos def _create_hoomd_simulation(snapshot, forces, run_on_gpu): From 65908a5353ef399a7d0fc8ee1a2957fe914a4a75 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sun, 22 Jun 2025 22:04:32 +0100 Subject: [PATCH 033/123] move parameters around --- mbuild/simulation.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 837f8eff1..2aed9fe73 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -19,15 +19,15 @@ def remove_overlaps_fire( compound, forcefield, + fire_iteration_steps, + num_fire_iterations, A_initial=10, bond_k_scale=100, angle_k_scale=100, - dt=1e-4, + dt=1e-5, force_tol=1e-2, angmom_tol=1e-2, energy_tol=1e-3, - fire_iteration_steps=5000, - num_fire_iterations=2, final_relaxation_steps=5000, run_on_gpu=True, ): @@ -81,7 +81,7 @@ def remove_overlaps_fire( angmom_tol=angmom_tol, energy_tol=energy_tol, finc_dt=1.1, - fdec_dt=0.5, + fdec_dt=0.4, alpha_start=0.2, fdec_alpha=0.95, min_steps_adapt=5, From b6a144ef2d1ed731a1b4cf42b4f0b71d37b0548a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:10:38 +0000 Subject: [PATCH 034/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.13 → v0.12.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.13...v0.12.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c4f2389b2..d46f3a92a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.13 + rev: v0.12.0 hooks: # Run the linter. - id: ruff From 497ca87cee06a3a13c5c1f9fc23e26dea8b504bf Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Jun 2025 21:10:50 +0000 Subject: [PATCH 035/123] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- setup.cfg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index f0dc4f89d..2d1c3039d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,19 +6,19 @@ message = Bump to version {new_version} tag_name = {new_version} [coverage:run] -omit = +omit = mbuild/examples/* mbuild/tests/* [coverage:report] -exclude_lines = +exclude_lines = pragma: no cover - + if 0: if __name__ == .__main__.: def __repr__ except ImportError -omit = +omit = mbuild/examples/* mbuild/tests/* From d1c93fc83fa39f86b087d74a73d46ceeccba3a3a Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 25 Jun 2025 11:07:44 +0100 Subject: [PATCH 036/123] add bond graph to lamellar path, create class for straight path --- mbuild/path.py | 57 +++++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index ed7f690dc..c0728e22a 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -53,13 +53,16 @@ class that others can inherit from then implement their own approach? class Path: def __init__(self, N=None, bond_graph=None, coordinates=None): self.bond_graph = bond_graph - if N and coordinates is None: + # Only N is defined, make empty coordinates + if N is not None and coordinates is None: self.N = N self.coordinates = np.zeros((N, 3)) - elif coordinates is not None and not N: + # Only coordinates is defined, set N from length + elif coordinates is not None and N is None: self.N = len(coordinates) self.coordinates = coordinates - elif coordinates is not None and N: + # Neither is defined, use list for coordinates + elif N is None and coordinates is None: self.N = N self.coordinates = [] else: @@ -68,7 +71,7 @@ def __init__(self, N=None, bond_graph=None, coordinates=None): @classmethod def from_coordinates(cls, coordinates, bond_graph=None): - return cls(coordinates=coordinates, bond_graph=bond_graph) + return cls(coordinates=coordinates, bond_graph=bond_graph, N=None) @abstractmethod def generate(self): @@ -119,6 +122,7 @@ def to_compound(self, bead_name="Bead", bead_mass=1): return compound def apply_mapping(self): + # TODO: Finish, add logic to align orientation with path site pos and bond graph """Mapping other compounds onto a Path's coordinates""" pass @@ -167,7 +171,6 @@ def generate(self): ] ) self.coordinates[1] = next_pos - # self.coordinates[1] = _next_coordinate(pos1=self.coordinates[0]) self.count += 1 # We already have 1 accepted move while self.count < self.N - 1: new_xyz = self._next_coordinate( @@ -188,24 +191,16 @@ def generate(self): "Try changing the parameters and running again.", ) - def _next_coordinate(self, pos1, pos2=None): - # if pos2 is None: - # phi = np.random.uniform(0, 2 * np.pi) - # theta = np.random.uniform(0, np.pi) - # next_pos = np.array( - # [ - # self.bond_length * np.sin(theta) * np.cos(phi), - # self.bond_length * np.sin(theta) * np.sin(phi), - # self.bond_length * np.cos(theta), - # ] - # ) - # else: # Get the last bond vector, use angle range with last 2 coords. + def _next_coordinate(self, pos1, pos2): + # Vector formed by previous 2 coordinates v1 = pos2 - pos1 v1_norm = v1 / np.linalg.norm(v1) theta = np.random.uniform(self.min_angle, self.max_angle) + # Pick random vector and center around origin (0,0,0) r = np.random.rand(3) - 0.5 r_perp = r - np.dot(r, v1_norm) * v1_norm r_perp_norm = r_perp / np.linalg.norm(r_perp) + # New vector, rotated relative to v1 v2 = np.cos(theta) * v1_norm + np.sin(theta) * r_perp_norm next_pos = v2 * self.bond_length @@ -228,13 +223,15 @@ def _check_path(self): return True -class Lamellae(Path): - def __init__(self, num_layers, layer_separation, layer_length, bond_length): +class Lamellar(Path): + def __init__( + self, num_layers, layer_separation, layer_length, bond_length, bond_graph=None + ): self.num_layers = num_layers self.layer_separation = layer_separation self.layer_length = layer_length self.bond_length = bond_length - super(Lamellae, self).__init__() + super(Lamellar, self).__init__(N=None, bond_graph=bond_graph) def generate(self): layer_spacing = np.arange(0, self.layer_length, self.bond_length) @@ -270,8 +267,24 @@ def generate(self): self.coordinates.extend(layer + arc) else: self.coordinates.extend(layer) - for i in range(len(self.coordinates) - 1): - self.bonds.append([i, i + 1]) + + def _next_coordinate(self): + pass + + def _check_path(self): + pass + + +class StraightLine(Path): + def __init__(self, spacing, N, direction=(1, 0, 0), bond_graph=None): + self.spacing = spacing + self.direction = np.asarray(direction) + super(StraightLine, self).__init__(N=N, bond_graph=bond_graph) + + def generate(self): + self.coordinates = np.array( + [np.zeros(3) + i * self.spacing * self.direction for i in range(self.N + 1)] + ) def _next_coordinate(self): pass From 705c940aa51be75bd18381ab5596e616cae6b943 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 25 Jun 2025 15:45:23 +0100 Subject: [PATCH 037/123] Save self.add for end of build, add head first, all monomers, then tail, to maintain expected order of polymer chain children --- mbuild/compound.py | 4 ++-- mbuild/path.py | 4 ++-- mbuild/polymer.py | 18 +++++++++++++++--- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 9b3d3e95e..9b85b59ba 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -262,8 +262,8 @@ def set_bond_graph(self, new_graph): """ if new_graph.number_of_nodes() != self.n_particles: raise ValueError( - f"new_graph contains {new_graph.number_of_nodes()}", - f" but the Compound only contains {self.n_particles}", + f"new_graph contains {new_graph.number_of_nodes()} nodes", + f" but the Compound contains {self.n_particles} particles", "The graph must contain one node per particle.", ) graph = BondGraph() diff --git a/mbuild/path.py b/mbuild/path.py index c0728e22a..e42410f72 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -118,7 +118,7 @@ def to_compound(self, bead_name="Bead", bead_mass=1): for xyz in self.coordinates: compound.add(Compound(name=bead_name, mass=bead_mass, pos=xyz)) if self.bond_graph: - compound.update_bond_graph(self.bond_graph) + compound.set_bond_graph(self.bond_graph) return compound def apply_mapping(self): @@ -283,7 +283,7 @@ def __init__(self, spacing, N, direction=(1, 0, 0), bond_graph=None): def generate(self): self.coordinates = np.array( - [np.zeros(3) + i * self.spacing * self.direction for i in range(self.N + 1)] + [np.zeros(3) + i * self.spacing * self.direction for i in range(self.N)] ) def _next_coordinate(self): diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 6a9572833..0bab099ef 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -207,6 +207,8 @@ def build(self, n, sequence="A", add_hydrogens=True): """ if n < 1: raise ValueError("n must be 1 or more") + head_and_tail_compounds = [] + repeat_compounds = [] for monomer in self._monomers: for label in self._port_labels: @@ -225,7 +227,8 @@ def build(self, n, sequence="A", add_hydrogens=True): last_part = None for n_added, seq_item in enumerate(it.cycle(sequence)): this_part = clone(seq_map[seq_item]) - self.add(this_part, "monomer[$]") + repeat_compounds.append(this_part) + # self.add(this_part, "monomer[$]") if last_part is None: first_part = this_part else: @@ -248,7 +251,8 @@ def build(self, n, sequence="A", add_hydrogens=True): if compound is not None: if self._headtail[i] is not None: head_tail[i].update_separation(self._headtail[i]) - self.add(compound) + # self.add(compound) + head_and_tail_compounds.append(compound) force_overlap(compound, compound.labels["up"], head_tail[i]) head_tail[i] = None else: @@ -279,12 +283,20 @@ def build(self, n, sequence="A", add_hydrogens=True): for port in self.all_ports(): if id(port) not in port_ids: self.remove(port) + if self.end_groups[0]: + self.add(self.end_groups[0]) + for compound in repeat_compounds: + self.add(new_child=compound, label="monomer[$]") + if self.end_groups[1]: + self.add(self.end_groups[1]) def build_from_path( self, path, sequence="A", add_hydrogens=True, energy_minimize=True ): self.build( - n=len(path.coordinates), sequence=sequence, add_hydrogens=add_hydrogens + n=len(path.coordinates) - len(self._end_groups), + sequence=sequence, + add_hydrogens=add_hydrogens, ) self.set_monomer_positions( coordinates=path.coordinates, energy_minimize=energy_minimize From 32b3f95846fed9a7192b33c7a4ed2e6556b2c84a Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 26 Jun 2025 11:26:21 +0100 Subject: [PATCH 038/123] add parameter to bond head and tail monomers without having to create a periodic bond, useful for cyclic polymers --- mbuild/polymer.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 0bab099ef..e07639fd3 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -167,7 +167,7 @@ def straighten(self, axis=(1, 0, 0), energy_minimize=True): ) self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def build(self, n, sequence="A", add_hydrogens=True): + def build(self, n, sequence="A", add_hydrogens=True, bond_head_tail=False): """Connect one or more components in a specified sequence. Uses the compounds that are stored in Polymer.monomers and @@ -205,6 +205,11 @@ def build(self, n, sequence="A", add_hydrogens=True): If ``None``, ``end_groups`` compound is None, and ``add_hydrogens`` is False then the head or tail port will be exposed in the polymer. """ + if add_hydrogens and bond_head_tail: + raise ValueError( + "In order to bond head and tail ports, the Polymer instance cannot contain " + "end_groups and add_hydrogens must be set to `False`" + ) if n < 1: raise ValueError("n must be 1 or more") head_and_tail_compounds = [] @@ -290,13 +295,23 @@ def build(self, n, sequence="A", add_hydrogens=True): if self.end_groups[1]: self.add(self.end_groups[1]) + if bond_head_tail: + force_overlap(self, self.head_port, self.tail_port) + def build_from_path( - self, path, sequence="A", add_hydrogens=True, energy_minimize=True + self, + path, + sequence="A", + add_hydrogens=True, + bond_head_tail=False, + energy_minimize=True, ): + n = len(path.coordinates) - sum([i for i in self.end_groups if i is not None]) self.build( - n=len(path.coordinates) - len(self._end_groups), + n=n, sequence=sequence, add_hydrogens=add_hydrogens, + bond_head_tail=bond_head_tail, ) self.set_monomer_positions( coordinates=path.coordinates, energy_minimize=energy_minimize From 52888852d6dc675a23fa71ac7f31443a982f88f1 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 26 Jun 2025 11:32:40 +0100 Subject: [PATCH 039/123] Add CyclicPath class --- mbuild/path.py | 45 +++++++++++++++++++++++++++++++++++++++++---- mbuild/polymer.py | 2 +- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index e42410f72..c00f47e3a 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -43,25 +43,30 @@ class that others can inherit from then implement their own approach? DeterministicPath ideas: - Lamellar layers - DNA strands +- Cyclic polymers Some combination of these? - Lamellar + random walk to generate semi-crystalline like structures? -- +- Make a new path by adding together multiple paths +- Some kind of data structure/functionality for new_path = Path(start_from_path=other_path) """ class Path: def __init__(self, N=None, bond_graph=None, coordinates=None): self.bond_graph = bond_graph - # Only N is defined, make empty coordinates + # Only N is defined, make empty coordinates with size N + # Use case: Random walks if N is not None and coordinates is None: self.N = N self.coordinates = np.zeros((N, 3)) # Only coordinates is defined, set N from length + # Use case: class method from_coordinates elif coordinates is not None and N is None: self.N = len(coordinates) self.coordinates = coordinates # Neither is defined, use list for coordinates + # Use case: Lamellar - Won't know N initially elif N is None and coordinates is None: self.N = N self.coordinates = [] @@ -123,13 +128,17 @@ def to_compound(self, bead_name="Bead", bead_mass=1): def apply_mapping(self): # TODO: Finish, add logic to align orientation with path site pos and bond graph - """Mapping other compounds onto a Path's coordinates""" + """Mapping other compounds onto a Path's coordinates + + mapping = {"A": "c1ccccc1C=C", "B": "C=CC=C"} + """ pass def _path_history(self): """Maybe this is a method that can be used optionally. We could add a save_history parameter to __init__. Depending on the approach, saving histories might add additional computation time and resources. + """ pass @@ -203,7 +212,6 @@ def _next_coordinate(self, pos1, pos2): # New vector, rotated relative to v1 v2 = np.cos(theta) * v1_norm + np.sin(theta) * r_perp_norm next_pos = v2 * self.bond_length - return pos1 + next_pos def _check_path(self): @@ -291,3 +299,32 @@ def _next_coordinate(self): def _check_path(self): pass + + +class CyclicPath(Path): + def __init__(self, spacing=None, N=None, radius=None, bond_graph=None): + self.spacing = spacing + self.radius = radius + n_params = sum(1 for i in (spacing, N, radius) if i is not None) + if n_params != 2: + raise ValueError("You must specify only 2 of spacing, N and radius.") + super(CyclicPath, self).__init__(N=N, bond_graph=bond_graph) + + def generate(self): + if self.spacing and self.N: + self.radius = (self.N * self.spacing) / (2 * np.pi) + elif self.radius and self.spacing: + self.N = int((2 * np.pi * self.radius) / self.spacing) + else: + self.spacing = (2 * np.pi) / self.N + + angles = np.arange(0, 2 * np.pi, (2 * np.pi) / self.N) + self.coordinates = np.array( + [(np.cos(a) * self.radius, np.sin(a) * self.radius, 0) for a in angles] + ) + + def _next_coordinate(self): + pass + + def _check_path(self): + pass diff --git a/mbuild/polymer.py b/mbuild/polymer.py index e07639fd3..b02026889 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -306,7 +306,7 @@ def build_from_path( bond_head_tail=False, energy_minimize=True, ): - n = len(path.coordinates) - sum([i for i in self.end_groups if i is not None]) + n = len(path.coordinates) - sum([1 for i in self.end_groups if i is not None]) self.build( n=n, sequence=sequence, From 9d83ecf9c6bb8ac6b2ee1e0ede1a0810470daaa9 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 26 Jun 2025 13:30:44 +0100 Subject: [PATCH 040/123] use numpy 2.0 in env files, and update tests --- environment-dev.yml | 2 +- environment.yml | 2 +- mbuild/tests/test_lattice.py | 2 +- mbuild/tests/test_packing.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index d28d6d7d8..a186aae5d 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -4,7 +4,7 @@ channels: dependencies: - python>=3.9,<=3.12 - boltons - - numpy=1.26.4 + - numpy>=2.2,<2.3 - sympy - unyt>=2.9.5 - boltons diff --git a/environment.yml b/environment.yml index 2a9defc89..89c7e596c 100644 --- a/environment.yml +++ b/environment.yml @@ -3,7 +3,7 @@ channels: - conda-forge dependencies: - ele - - numpy=1.26.4 + - numpy>=2.2,<2.3 - packmol>=20.15 - gmso>=0.9.0 - garnett diff --git a/mbuild/tests/test_lattice.py b/mbuild/tests/test_lattice.py index 4df70a7e5..c8207c457 100644 --- a/mbuild/tests/test_lattice.py +++ b/mbuild/tests/test_lattice.py @@ -172,7 +172,7 @@ def test_proper_angles(self, vectors, angles): (-1, 1, 1), (1, -1, 1), (1, 1, -1), - (1, 1, np.NaN), + (1, 1, np.nan), ], ) def test_incorrect_populate_inputs(self, x, y, z): diff --git a/mbuild/tests/test_packing.py b/mbuild/tests/test_packing.py index db2a3edaf..09d8137b7 100644 --- a/mbuild/tests/test_packing.py +++ b/mbuild/tests/test_packing.py @@ -116,7 +116,7 @@ def test_fill_sphere(self, h2o): assert filled.n_bonds == 50 * 2 center = np.array([3.0, 3.0, 3.0]) - assert np.alltrue(np.linalg.norm(filled.xyz - center, axis=1) < 1.5) + assert np.all(np.linalg.norm(filled.xyz - center, axis=1) < 1.5) def test_fill_sphere_density(self, h2o): filled = mb.fill_sphere(h2o, sphere=[3, 3, 3, 1.5], density=1000) From 882ec4368f560b1cc32bde3a4bb546f433c56ba0 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 26 Jun 2025 13:38:38 +0100 Subject: [PATCH 041/123] fix numpy version --- environment-dev.yml | 2 +- environment.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index a186aae5d..ad4f72bda 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -4,7 +4,7 @@ channels: dependencies: - python>=3.9,<=3.12 - boltons - - numpy>=2.2,<2.3 + - numpy>=2.0,<2.3 - sympy - unyt>=2.9.5 - boltons diff --git a/environment.yml b/environment.yml index 89c7e596c..28cc48741 100644 --- a/environment.yml +++ b/environment.yml @@ -3,7 +3,7 @@ channels: - conda-forge dependencies: - ele - - numpy>=2.2,<2.3 + - numpy>=2.0,<2.3 - packmol>=20.15 - gmso>=0.9.0 - garnett From f25a55f8ebf71247d7db3305443d6f8471768031 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 26 Jun 2025 13:55:52 +0100 Subject: [PATCH 042/123] add upper bound for rdkit --- environment-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment-dev.yml b/environment-dev.yml index ad4f72bda..546149f24 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -26,7 +26,7 @@ dependencies: - packmol>=20.15 - pytest-cov - pycifrw - - rdkit>=2021 + - rdkit>=2021,<2025.3.3 - requests - requests-mock - scipy From bff80127e90daaf8051859d4c158b7c778c8c952 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 26 Jun 2025 22:59:47 +0100 Subject: [PATCH 043/123] add doc strings --- mbuild/path.py | 39 ++++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index c00f47e3a..9110b24ba 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -39,6 +39,7 @@ class that others can inherit from then implement their own approach? - Random walk (tons of possibilities here) - Branching - Multiple self-avoiding random walks +- ?? DeterministicPath ideas: - Lamellar layers @@ -49,13 +50,16 @@ class that others can inherit from then implement their own approach? - Lamellar + random walk to generate semi-crystalline like structures? - Make a new path by adding together multiple paths - Some kind of data structure/functionality for new_path = Path(start_from_path=other_path) + +Other TODOs: + Make coordinates a property with a setter? use self_coordinates for initial condition """ class Path: - def __init__(self, N=None, bond_graph=None, coordinates=None): + def __init__(self, N=None, coordinates=None, bond_graph=None): self.bond_graph = bond_graph - # Only N is defined, make empty coordinates with size N + # Only N is defined, make empty coordinates array with size N # Use case: Random walks if N is not None and coordinates is None: self.N = N @@ -86,8 +90,6 @@ def generate(self): ----------------- - Set initial conditions - Implement Path generation steps by calling _next_coordinate() and _check_path() - - Update bonding info depending on specific path approach - - Ex) Random walk will always bond consecutive beads together - Handle cases of next coordiante acceptance - Handle cases of next coordinate rejection """ @@ -104,6 +106,7 @@ def _check_path(self): pass def neighbor_list(self, r_max, coordinates=None, box=None): + """Use freud to create a neighbor list of a set of coordinates.""" if coordinates is None: coordinates = self.coordinates if box is None: @@ -138,7 +141,7 @@ def _path_history(self): """Maybe this is a method that can be used optionally. We could add a save_history parameter to __init__. Depending on the approach, saving histories might add additional computation time and resources. - + Might be useful for more complicated random walks/branching algorithms """ pass @@ -156,6 +159,32 @@ def __init__( tolerance=1e-5, bond_graph=None, ): + """Generates coordinates from a self avoiding random walk using + fixed bond lengths, hard spheres, and minimum and maximum angles + formed by 3 consecutive points. + + Possible angle values are sampled uniformly between min_angle + and max_angle between a new site and the two previous sites. + + Parameters: + ----------- + bond_length : float, required + Fixed bond length between 2 coordinates. + radius : float, required + Radius of sites used in checking for overlaps. + min_angle : float, required + Minimum angle used when randomly selecting angle + for the next step. + max_angle : float, required + Maximum angle used when randomly selecting angle + for the next step. + seed : int, default = 42 + Random seed + tolerance : float, default = 1e-5 + Tolerance used for rounding. + bond_graph : networkx.graph.Graph; optional + Sets the bonding of sites along the path. + """ self.bond_length = bond_length self.radius = radius self.min_angle = min_angle From 1308bca91a2e2d39646d29091aadda4136ab4b2f Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 26 Jun 2025 23:23:52 +0100 Subject: [PATCH 044/123] add conditional install of openbabel to CI --- .github/workflows/CI.yaml | 6 +++++- environment-dev.yml | 3 +-- mbuild/utils/io.py | 3 +++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index a1af8e8ed..0704ff76f 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.9", "3.10", "3.11", "3.12"] + python-version: ["3.10", "3.11", "3.12", "3.13"] defaults: run: @@ -39,6 +39,10 @@ jobs: - name: Install Package run: python -m pip install -e . + - name: Conditionally install OpenBabel + if: ${{ matrix.python-version != '3.13' }} + run: micromamba install -y openbabel + - name: Test (OS -> ${{ matrix.os }} / Python -> ${{ matrix.python-version }}) run: python -m pytest -v --cov=mbuild --cov-report=xml --cov-append --cov-config=setup.cfg --color yes --pyargs mbuild diff --git a/environment-dev.yml b/environment-dev.yml index 546149f24..371dc98c2 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -18,8 +18,7 @@ dependencies: - nglview>=3 - pytest - garnett>=0.7.1 - - openbabel>=3.0.0 - - openff-toolkit-base >=0.11,<0.16.7 + - openff-toolkit-base>0.16.7 - openmm - gsd>=2.9 - parmed>=3.4.3 diff --git a/mbuild/utils/io.py b/mbuild/utils/io.py index 9833a50c7..b897fffd4 100644 --- a/mbuild/utils/io.py +++ b/mbuild/utils/io.py @@ -88,6 +88,9 @@ class DelayImportError(ImportError, SkipTest): # conda install -c conda-forge openbabel +NOTE: openbabel is only available for python<3.13. +If you need it in your environment, make sure your python is 3.10, 3.11 or 3.12. + or from source following instructions at: # http://openbabel.org/docs/current/UseTheLibrary/PythonInstall.html From 505696db5a4ef942d72e82180b5943607a010a53 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 26 Jun 2025 23:37:34 +0100 Subject: [PATCH 045/123] update environment-dev file to match gmso --- environment-dev.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/environment-dev.yml b/environment-dev.yml index 371dc98c2..6c049177b 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -2,7 +2,7 @@ name: mbuild-dev channels: - conda-forge dependencies: - - python>=3.9,<=3.12 + - python>=3.10,<=3.13 - boltons - numpy>=2.0,<2.3 - sympy @@ -10,7 +10,6 @@ dependencies: - boltons - lark>=1.2 - lxml - - freud>=3.0 - intermol - mdtraj - pydantic>=2 @@ -21,11 +20,12 @@ dependencies: - openff-toolkit-base>0.16.7 - openmm - gsd>=2.9 + - freud>=3.2 - parmed>=3.4.3 - packmol>=20.15 - pytest-cov - pycifrw - - rdkit>=2021,<2025.3.3 + - rdkit>=2021 - requests - requests-mock - scipy @@ -38,8 +38,8 @@ dependencies: - pandas - symengine - python-symengine - - hoomd>=4.0,<5.0 - py3Dmol + - hoomd>=4.0 - pip: - git+https://github.com/mosdef-hub/gmso.git@main - git+https://github.com/mosdef-hub/foyer.git@main From 373ff6832fda158636d5bd534181fc95bfbd968f Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 27 Jun 2025 09:16:01 +0100 Subject: [PATCH 046/123] use py313 in arch test --- .github/workflows/CI.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 0704ff76f..8afa971f6 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -19,7 +19,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12"] defaults: run: @@ -40,7 +40,7 @@ jobs: run: python -m pip install -e . - name: Conditionally install OpenBabel - if: ${{ matrix.python-version != '3.13' }} + if: ${{ matrix.python-version != "3.13" }} run: micromamba install -y openbabel - name: Test (OS -> ${{ matrix.os }} / Python -> ${{ matrix.python-version }}) @@ -61,7 +61,7 @@ jobs: fail-fast: false matrix: os: [macOS-latest, macOS-13, ubuntu-latest] - python-version: ["3.12"] + python-version: ["3.13"] defaults: run: From 8bf5276eeb449c5170f6b3da61a09192e7f7d4f2 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 27 Jun 2025 09:29:14 +0100 Subject: [PATCH 047/123] fix str --- .github/workflows/CI.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 8afa971f6..dd2f754e5 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -40,7 +40,7 @@ jobs: run: python -m pip install -e . - name: Conditionally install OpenBabel - if: ${{ matrix.python-version != "3.13" }} + if: ${{ matrix.python-version != '3.13' }} run: micromamba install -y openbabel - name: Test (OS -> ${{ matrix.os }} / Python -> ${{ matrix.python-version }}) From ae0fd74dc7fe1ddb2309b472e6f975a0353f77b9 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 27 Jun 2025 09:46:37 +0100 Subject: [PATCH 048/123] Use newer version of setup-wsl action --- .github/workflows/CI.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index dd2f754e5..7055d04ba 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -93,7 +93,7 @@ jobs: - uses: actions/checkout@v4 name: Checkout Branch / Pull Request - - uses: Vampire/setup-wsl@v4 + - uses: Vampire/setup-wsl@v5 with: distribution: Ubuntu-24.04 wsl-shell-user: runner From 252e4f8ad2ae43c71982793c2558edf417bbf564 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 27 Jun 2025 10:01:28 +0100 Subject: [PATCH 049/123] update environment.yml --- environment.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/environment.yml b/environment.yml index 28cc48741..b6293aec9 100644 --- a/environment.yml +++ b/environment.yml @@ -5,11 +5,11 @@ dependencies: - ele - numpy>=2.0,<2.3 - packmol>=20.15 - - gmso>=0.9.0 + - gmso>=0.12.0 - garnett - parmed>=3.4.3 - pycifrw - - python>=3.8 + - python>=3.10,<=3.13 - rdkit>=2021 - scipy - networkx From 3f8539ea9646eb22739d44dd8d9399087f4286fb Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 27 Jun 2025 15:50:11 +0100 Subject: [PATCH 050/123] Add Knot class to path.py --- mbuild/path.py | 47 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/mbuild/path.py b/mbuild/path.py index 9110b24ba..1e53d8a5d 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -5,6 +5,7 @@ import freud import numpy as np +from scipy.interpolate import interp1d from mbuild import Compound from mbuild.utils.geometry import bounding_box @@ -52,7 +53,7 @@ class that others can inherit from then implement their own approach? - Some kind of data structure/functionality for new_path = Path(start_from_path=other_path) Other TODOs: - Make coordinates a property with a setter? use self_coordinates for initial condition + Make coordinates a property with a setter? Keep as a plain attribute? """ @@ -357,3 +358,47 @@ def _next_coordinate(self): def _check_path(self): pass + + +class Knot(Path): + def __init__(self, spacing, N, m, bond_graph=None): + self.spacing = spacing + self.m = m + super(Knot, self).__init__(N=N, bond_graph=bond_graph) + + def generate(self): + t_dense = np.linspace(0, 2 * np.pi, 5000) + # Base (unscaled) curve + if self.m == 3: # Trefoil knot (3_1) + R, r = 1.0, 0.3 + x = (R + r * np.cos(3 * t_dense)) * np.cos(2 * t_dense) + y = (R + r * np.cos(3 * t_dense)) * np.sin(2 * t_dense) + z = r * np.sin(3 * t_dense) + elif self.m == 4: # Figure-eight knot (4_1) + x = (2 + np.cos(2 * t_dense)) * np.cos(3 * t_dense) + y = (2 + np.cos(2 * t_dense)) * np.sin(3 * t_dense) + z = np.sin(4 * t_dense) + elif self.m == 5: # Cinquefoil knot (5_1), a (5,2) torus knot + R, r = 1.0, 0.3 + x = (R + r * np.cos(5 * t_dense)) * np.cos(2 * t_dense) + y = (R + r * np.cos(5 * t_dense)) * np.sin(2 * t_dense) + z = r * np.sin(5 * t_dense) + else: + raise ValueError("Only m=3, m=4 and m=5 are supported.") + # Compute arc length of base curve + coords_dense = np.stack((x, y, z), axis=1) + deltas = np.diff(coords_dense, axis=0) + dists = np.linalg.norm(deltas, axis=1) + arc_lengths = np.concatenate([[0], np.cumsum(dists)]) + base_length = arc_lengths[-1] + L_target = (self.N - 1) * self.spacing + # Scale to match target contour length + scale = L_target / base_length + coords_dense *= scale + arc_lengths *= scale + # Resample uniformly along arc length based on target separation and N sites + desired_arcs = np.linspace(0, L_target, self.N, endpoint=False) + x_interp = interp1d(arc_lengths, coords_dense[:, 0])(desired_arcs) + y_interp = interp1d(arc_lengths, coords_dense[:, 1])(desired_arcs) + z_interp = interp1d(arc_lengths, coords_dense[:, 2])(desired_arcs) + self.coordinates = np.stack((x_interp, y_interp, z_interp), axis=1) From a0e94b7734cbef0e626eb9497ecb106b153e5fe3 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 30 Jun 2025 14:11:37 +0100 Subject: [PATCH 051/123] update python version in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 72d787167..f4832fa20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers=[ "Operating System :: MacOS", ] urls = {Homepage = "https://github.com/mosdef-hub/mbuild"} -requires-python = ">=3.9" +requires-python = ">=3.10" dynamic = ["version"] [tool.setuptools] From db67b1fe79dd47ace2e599df74d1a6602465b6a0 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 30 Jun 2025 14:12:52 +0100 Subject: [PATCH 052/123] update python version in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9d51a1c21..d059fe327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers=[ "Operating System :: MacOS", ] urls = {Homepage = "https://github.com/mosdef-hub/mbuild"} -requires-python = ">=3.9" +requires-python = ">=3.10" dynamic = ["version"] [tool.setuptools] From ea7a20d3a5ffc155c859eef32f370ea40cbb92c7 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 30 Jun 2025 16:14:14 +0100 Subject: [PATCH 053/123] Add note about openbabel and python 3.13 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 74b359520..23e21d571 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,8 @@ conda activate mbuild-dev pip install . ``` +NOTE: [openbabel](https://github.com/openbabel/openbabel) is required for some energy minimization methods in `mbuild.compound.Compound()`. It can be installed into your mBuild environment from conda-forge with `conda install -c conda-forge openbabel`; however, openbabel does not yet support python 3.13. + #### Install an editable version from source Once all dependencies have been installed and the ``conda`` environment has been created, the ``mBuild`` itself can be installed. From 57f24fd26bd8ebd242f83b66b51140879a48b3ca Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 20:50:29 +0000 Subject: [PATCH 054/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.0 → v0.12.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.0...v0.12.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d46f3a92a..b1812f306 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.0 + rev: v0.12.1 hooks: # Run the linter. - id: ruff From dbeb1b41b591c8297be64c1e6d4b3846d684af7d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 3 Jul 2025 12:24:10 +0100 Subject: [PATCH 055/123] Add zig-zag, add 3rd dimension to lamellar class --- mbuild/path.py | 200 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 192 insertions(+), 8 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index 1e53d8a5d..aebd2a3b0 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -28,13 +28,13 @@ class that others can inherit from then implement their own approach? We can define abstract methods for these in Path. We can put universally useful methods in Path as well (e.g., n_list(), to_compound(), etc..). -Some paths (lamellar structures) would kind of just do +Some paths (lamellar, cyclic, spiral, etc..) would kind of just do everything in generate() without having to use next_coordinate() or check_path(). These would still need to be defined, but just left empty and/or always return True in the case of check_path. Maybe that means these kinds of "paths" need a different data structure? -Do we just have RandomPath and DeterministicPath? +Do we have RandomPath and DeterministicPath? RandomPath ideas: - Random walk (tons of possibilities here) @@ -44,8 +44,10 @@ class that others can inherit from then implement their own approach? DeterministicPath ideas: - Lamellar layers -- DNA strands - Cyclic polymers +- Helix +- Spiral +- Knots Some combination of these? - Lamellar + random walk to generate semi-crystalline like structures? @@ -263,22 +265,32 @@ def _check_path(self): class Lamellar(Path): def __init__( - self, num_layers, layer_separation, layer_length, bond_length, bond_graph=None + self, + num_layers, + layer_separation, + layer_length, + bond_length, + num_stacks=1, + stack_separation=None, + bond_graph=None, ): self.num_layers = num_layers self.layer_separation = layer_separation self.layer_length = layer_length self.bond_length = bond_length + self.num_stacks = num_stacks + self.stack_separation = stack_separation super(Lamellar, self).__init__(N=None, bond_graph=bond_graph) def generate(self): layer_spacing = np.arange(0, self.layer_length, self.bond_length) - # Info for generating coords of the curves between layers + # Info needed for generating coords of the curves between layers r = self.layer_separation / 2 arc_length = r * np.pi arc_num_points = math.floor(arc_length / self.bond_length) arc_angle = np.pi / (arc_num_points + 1) # incremental angle arc_angles = np.linspace(arc_angle, np.pi, arc_num_points, endpoint=False) + # stack_coordinates = [] for i in range(self.num_layers): if i % 2 == 0: # Even layer; build from left to right layer = [ @@ -305,6 +317,43 @@ def generate(self): self.coordinates.extend(layer + arc) else: self.coordinates.extend(layer) + if self.num_stacks > 1: + first_stack_coordinates = np.copy(np.array(self.coordinates)) + # Now find info for curves between stacked layers + r = self.stack_separation / 2 + arc_length = r * np.pi + arc_num_points = math.floor(arc_length / self.bond_length) + arc_angle = np.pi / (arc_num_points + 1) # incremental angle + arc_angles = np.linspace(arc_angle, np.pi, arc_num_points, endpoint=False) + if self.num_layers % 2 == 0: + odd_stack_mult = -1 + else: + odd_stack_mult = 1 + for i in range(1, self.num_stacks): + if i % 2 != 0: # Odd stack + this_stack = np.copy(first_stack_coordinates[::-1]) + np.array( + [0, 0, self.stack_separation * i] + ) + origin = self.coordinates[-1] + np.array([0, 0, r]) + arc = [ + origin + + np.array([0, odd_stack_mult * np.sin(theta), np.cos(theta)]) + * r + for theta in arc_angles + ] + self.coordinates.extend(arc[::-1]) + self.coordinates.extend(list(this_stack)) + elif i % 2 == 0: # Even stack + this_stack = np.copy(first_stack_coordinates) + np.array( + [0, 0, self.stack_separation * i] + ) + origin = self.coordinates[-1] + np.array([0, 0, r]) + arc = [ + origin + np.array([0, -np.sin(theta), np.cos(theta)]) * r + for theta in arc_angles + ] + self.coordinates.extend(arc[::-1]) + self.coordinates.extend(list(this_stack)) def _next_coordinate(self): pass @@ -331,14 +380,14 @@ def _check_path(self): pass -class CyclicPath(Path): +class Cyclic(Path): def __init__(self, spacing=None, N=None, radius=None, bond_graph=None): self.spacing = spacing self.radius = radius n_params = sum(1 for i in (spacing, N, radius) if i is not None) if n_params != 2: raise ValueError("You must specify only 2 of spacing, N and radius.") - super(CyclicPath, self).__init__(N=N, bond_graph=bond_graph) + super(Cyclic, self).__init__(N=N, bond_graph=bond_graph) def generate(self): if self.spacing and self.N: @@ -385,7 +434,7 @@ def generate(self): z = r * np.sin(5 * t_dense) else: raise ValueError("Only m=3, m=4 and m=5 are supported.") - # Compute arc length of base curve + # Compute arc length of a base curve coords_dense = np.stack((x, y, z), axis=1) deltas = np.diff(coords_dense, axis=0) dists = np.linalg.norm(deltas, axis=1) @@ -402,3 +451,138 @@ def generate(self): y_interp = interp1d(arc_lengths, coords_dense[:, 1])(desired_arcs) z_interp = interp1d(arc_lengths, coords_dense[:, 2])(desired_arcs) self.coordinates = np.stack((x_interp, y_interp, z_interp), axis=1) + + +class Helix(Path): + def __init__( + self, N, radius, rise, twist, right_handed=True, bottom_up=True, bond_graph=None + ): + """ + Generate helical path. + + Parameters: + ----------- + radius : float, required + Radius of the helix (nm) + rise : float, required + Rise per site on path (nm) + twist : float, required + Twist per site in path (degrees) + right_handed : bool, default True + Set the handedness of the helical twist + Set to false for a left handed twist + bottom_up : bool, default True + If True, the twist is in the positive Z direction + If False, the twist is in the negative Z direction + + Notes: + ------ + To create a double helix pair (e.g., DNA) create two paths + with opposite values for right_handed and bottom_up + """ + self.radius = radius + self.rise = rise + self.twist = twist + self.right_handed = right_handed + self.bottom_up = bottom_up + super(Helix, self).__init__(N=N, bond_graph=bond_graph) + + def generate(self): + indices = reversed(range(self.N)) if not self.bottom_up else range(self.N) + for i in indices: + angle = np.deg2rad(i * self.twist) + if not self.right_handed: + angle *= -1 + x = self.radius * np.cos(angle) + y = self.radius * np.sin(angle) + z = i * self.rise if self.bottom_up else -i * self.rise + self.coordinates[i] = (x, y, z) + + +class Spiral2D(Path): + def __init__(self, N, a, b, spacing, bond_graph=None): + """ + Generate a 2D spiral path in the XY plane. + + Parameters + ---------- + N: int, required + Number of sites in the path + a : float, required + The initial radius (nm) + b : float, required + Determines how fast radius grows per angle increment (r = a + bθ) + spacing : float, required + Distance between adjacent sites (nm) + """ + self.a = a + self.b = b + self.spacing = spacing + super().__init__(N=N, bond_graph=bond_graph) + + def generate(self): + theta = 0.0 + for i in range(self.N): + r = self.a + self.b * theta + x = r * np.cos(theta) + y = r * np.sin(theta) + z = 0.0 + self.coordinates[i] = (x, y, z) + # Estimate next angle increment based on arc length + ds_dtheta = np.sqrt((r) ** 2 + self.b**2) + dtheta = self.spacing / ds_dtheta + theta += dtheta + + +class ZigZag(Path): + def __init__( + self, + N, + spacing=1.0, + angle_deg=120.0, + sites_per_segment=1, + plane="xy", + bond_graph=None, + ): + self.spacing = spacing + self.angle_deg = angle_deg + self.sites_per_segment = sites_per_segment + self.plane = plane + if N % sites_per_segment != 0: + raise ValueError("N must be evenly divisible by sites_per_segment") + super(ZigZag, self).__init__(N=N, bond_graph=bond_graph) + + def generate(self): + angle_rad = np.deg2rad(self.angle_deg) + direction = np.array([1.0, 0.0]) + position = np.zeros(2) + coords = [] + step_count = 0 + segment_count = 0 + + for i in range(self.N): + coords.append(position.copy()) + position += self.spacing * direction + step_count += 1 + + # Rotate + if step_count == self.sites_per_segment: + step_count = 0 + sign = -1 if segment_count % 2 == 0 else 1 + rot_matrix = np.array( + [ + [np.cos(sign * angle_rad), -np.sin(sign * angle_rad)], + [np.sin(sign * angle_rad), np.cos(sign * angle_rad)], + ] + ) + direction = rot_matrix @ direction + segment_count += 1 + + # Map into 3D space based on chosen plane + for i, (x2d, y2d) in enumerate(coords): + if self.plane == "xy": + self.coordinates[i] = (x2d, y2d, 0) + elif self.plane == "xz": + self.coordinates[i] = (x2d, 0, y2d) + elif self.plane == "yz": + self.coordinates[i] = (0, x2d, y2d) From a88281b3ff056138cf027d2a6a5113e251582dd3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Jul 2025 21:45:54 +0000 Subject: [PATCH 056/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.1 → v0.12.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.1...v0.12.2) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b1812f306..c52d970a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.1 + rev: v0.12.2 hooks: # Run the linter. - id: ruff From eb85768081dd00826675510750e98030d641bff8 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 8 Jul 2025 21:39:52 +0100 Subject: [PATCH 057/123] Add EmbedMolecule when loading SMILES from rdkit --- mbuild/conversion.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mbuild/conversion.py b/mbuild/conversion.py index 178129703..3d1b4da31 100644 --- a/mbuild/conversion.py +++ b/mbuild/conversion.py @@ -835,6 +835,7 @@ def from_rdkit(rdkit_mol, compound=None, coords_only=False, smiles_seed=0): "to the RDKit error messages for possible fixes. You can also " "install openbabel and use the backend='pybel' instead" ) + AllChem.EmbedMolecule(mymol, useExpTorsionAnglePrefs=True, useBasicKnowledge=True) AllChem.UFFOptimizeMolecule(mymol) single_mol = mymol.GetConformer(0) # convert from Angstroms to nanometers From e66bd5214fcfea5c7b2ef09c675732b77dc697cf Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 9 Jul 2025 14:32:02 +0100 Subject: [PATCH 058/123] add embed option for to_rdkit, add volume method --- mbuild/compound.py | 15 +++++++++++++-- mbuild/conversion.py | 7 ++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 9b85b59ba..7e5d8e4cc 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -605,6 +605,17 @@ def charge(self, value): "not at the bottom of the containment hierarchy." ) + def volume(self): + """Estimate volume of the compound's particles. + Uses rdkit.Chem.AllChem.ComputeMolVolume + """ + from rdkit import Chem + + rdmol = self.to_rdkit(embed=True) + vol = Chem.AllChem.ComputeMolVolume(rdmol) + vol /= 1000 # Convert from cubic angstrom to cubic nm + return vol + def add( self, new_child, @@ -3058,7 +3069,7 @@ def from_parmed(self, structure, coords_only=False, infer_hierarchy=True): infer_hierarchy=infer_hierarchy, ) - def to_rdkit(self): + def to_rdkit(self, embed=False): """Create an RDKit RWMol from an mBuild Compound. Returns @@ -3085,7 +3096,7 @@ def to_rdkit(self): See https://www.rdkit.org/docs/GettingStartedInPython.html """ - return conversion.to_rdkit(self) + return conversion.to_rdkit(self, embed=embed) def to_parmed( self, diff --git a/mbuild/conversion.py b/mbuild/conversion.py index 3d1b4da31..06bcc878a 100644 --- a/mbuild/conversion.py +++ b/mbuild/conversion.py @@ -1707,7 +1707,7 @@ def to_pybel( return pybelmol -def to_rdkit(compound): +def to_rdkit(compound, embed=False): """Create an RDKit RWMol from an mBuild Compound. Parameters @@ -1763,6 +1763,11 @@ def to_rdkit(compound): rdkit_bond = temp_mol.GetBondBetweenAtoms(*bond_indices) rdkit_bond.SetBondType(bond_order_dict[bond[2]["bond_order"]]) + if embed: + Chem.SanitizeMol(temp_mol) + temp_mol = Chem.AddHs(temp_mol) + Chem.AllChem.EmbedMolecule(temp_mol) + return temp_mol From f09bad026c8ea43b8a53cff081f6186566af49ea Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 11 Jul 2025 22:53:11 +0100 Subject: [PATCH 059/123] rework hoomd sim funcs --- mbuild/simulation.py | 120 +++++++++++++++++++++++++++++-------------- 1 file changed, 82 insertions(+), 38 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 2aed9fe73..81093f735 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -16,6 +16,7 @@ from mbuild.utils.io import import_ +## HOOMD METHODS ## def remove_overlaps_fire( compound, forcefield, @@ -30,15 +31,18 @@ def remove_overlaps_fire( energy_tol=1e-3, final_relaxation_steps=5000, run_on_gpu=True, + seed=42, + gsd_file=None, ): - """Run a short HOOMD-Blue simulation to remove overlapping particles. + """Run a short HOOMD-Blue simulation with the FIRE integrator + to remove overlapping particles. Parameters: ----------- compound : mbuild.compound.Compound; required The compound to perform the displacement capped simulation with. forcefield : foyer.focefield.ForceField or gmso.core.Forcefield; required - The forcefield used for the simulation + The forcefield used for the simulation. steps : int; required The number of simulation steps to run max_displacement : float, default 1e-3 @@ -47,33 +51,26 @@ def remove_overlaps_fire( If true, attempts to run HOOMD-Blue on a GPU. If a GPU device isn't found, then it will run on the CPU """ - top = compound.to_gmso() - top.identify_connections() - apply(top, forcefields=forcefield) - - forces, ref = gmso.external.to_hoomd_forcefield(top, r_cut=0.4) - snap, ref = gmso.external.to_gsd_snapshot(top) - forces = list(set().union(*forces.values())) - lj = [f for f in forces if isinstance(f, hoomd.md.pair.LJ)][0] - bond = [f for f in forces if isinstance(f, hoomd.md.bond.Harmonic)][0] - angle = [f for f in forces if isinstance(f, hoomd.md.angle.Harmonic)][0] - + snap, forces = _compound_to_hoomd_snap_forces(compound, forcefield, r_cut=1.2) + lj, bond, angle = (None, None, None) + for force in forces: + if isinstance(force, hoomd.md.pair.LJ): + lj = force + if isinstance(force, hoomd.md.bond.Harmonic): + bond = force + elif isinstance(force, hoomd.md.angle.Harmonic): + angle = force # Make DPD force, pulling info from LJ dpd = hoomd.md.pair.DPDConservative(nlist=lj.nlist) for param in lj.params: dpd.params[param] = dict(A=A_initial) - dpd.r_cut[param] = lj.r_cut[param] dpd.r_cut[param] = lj.params[param]["sigma"] - # Scale bond K and angle K for param in bond.params: bond.params[param]["k"] /= bond_k_scale for param in angle.params: angle.params[param]["k"] /= angle_k_scale - - ### HOOMD SIMULATION - sim = hoomd.Simulation(device=hoomd.device.auto_select()) - sim.create_state_from_snapshot(snap) + # Set up and run nvt = hoomd.md.methods.ConstantVolume(filter=hoomd.filter.All()) fire = hoomd.md.minimize.FIRE( dt=dt, @@ -88,52 +85,99 @@ def remove_overlaps_fire( min_steps_conv=20, methods=[nvt], ) - fire.forces = [dpd, bond, angle] - sim.operations.integrator = fire - gsd_writer = hoomd.write.GSD( - filename="traj.gsd", - trigger=hoomd.trigger.Periodic(1000), - mode="wb", - filter=hoomd.filter.All(), + sim = _hoomd_fire_sim( + snapshot=snap, + forces=[dpd, bond, angle], + fire=fire, + run_on_gpu=run_on_gpu, + seed=seed, + gsd_file=gsd_file, ) - sim.operations.writers.append(gsd_writer) + # Run FIRE sims with DPD + scaled bonds and angles for sim_num in range(num_fire_iterations): - sim.run(fire_iteration_steps, write_at_start=True) - # Scale bond K and angle K + sim.run(fire_iteration_steps) + # Re-scale bonds and angle force constants, Run DPD sim again for param in bond.params: bond.params[param]["k"] *= bond_k_scale for param in angle.params: angle.params[param]["k"] *= angle_k_scale - sim.run(fire_iteration_steps, write_at_start=False) + sim.run(fire_iteration_steps) + # Replace DPD with initial LJ force, quick relax sim.operations.integrator.forces.remove(dpd) sim.operations.integrator.forces.append(lj) - for sim_num in range(num_fire_iterations): - sim.run(fire_iteration_steps, write_at_start=True) + sim.run(final_relaxation_steps) + # Update particle positions with sim.state.cpu_local_snapshot as snap: particles = snap.particles.rtag[:] pos = snap.particles.position[particles] compound.xyz = pos -def _create_hoomd_simulation(snapshot, forces, run_on_gpu): +def _compound_to_hoomd_snap_forces(compound, forcefield, r_cut): + # Convret to GMSO, apply forcefield + top = compound.to_gmso() + top.identify_connections() + apply(top, forcefields=forcefield) + # Get hoomd snapshot and force objects + forces, ref = gmso.external.to_hoomd_forcefield(top, r_cut=r_cut) + snap, ref = gmso.external.to_gsd_snapshot(top) + forces = list(set().union(*forces.values())) + return snap, forces + + +def _hoomd_fire_sim(snapshot, forces, run_on_gpu, seed, fire, gsd_file): + sim = _create_hoomd_simulation(snapshot, forces, run_on_gpu, seed, gsd_file) + fire.forces = forces + sim.operations.integrator = fire + return sim + + +def _hoomd_nvt_sim(snapshot, forces, run_on_gpu, seed, gsd_file): + sim = _create_hoomd_simulation(snapshot, forces, run_on_gpu, seed, gsd_file) + nvt = hoomd.md.methods.ConstantVolume(filter=hoomd.filter.All()) + integrator = hoomd.md.Integrator(dt=0.0001) + integrator.forces = forces + integrator.methods = [nvt] + sim.operations.integrator = integrator + return sim + + +def _hoomd_box_update(snapshot, forces, run_on_gpu, seed, gsd_file, target_box): + """Run a quick compression to expansion on an mBuild Compound.""" + sim = _create_hoomd_simulation(snapshot, forces, run_on_gpu, seed, gsd_file) + nvt = hoomd.md.methods.ConstantVolume(filter=hoomd.filter.All()) + integrator = hoomd.md.Integrator(dt=0.0001) + integrator.forces = forces + integrator.methods = [nvt] + sim.operations.integrator = integrator + return sim + + +def _create_hoomd_simulation(snapshot, forces, run_on_gpu, seed, gsd_file): # Set up common hoomd stuff here if run_on_gpu: try: device = hoomd.device.GPU() print(f"GPU found, running on device {device.device}") except RuntimeError: - print("GPU not found, running on CPU.") + print("GPU not found, running on CPU anyway.") device = hoomd.device.CPU() else: device = hoomd.device.CPU() - sim = hoomd.Simulation(device=device) + sim = hoomd.Simulation(device=device, seed=seed) sim.create_state_from_snapshot(snapshot) - integrator = hoomd.md.Integrator(dt=0.0001) - integrator.forces = forces - sim.operations.integrator = integrator + if gsd_file: + gsd_writer = hoomd.write.GSD( + filename=gsd_file, + trigger=hoomd.trigger.Periodic(1000), + mode="wb", + filter=hoomd.filter.All(), + ) + sim.operations.writers.append(gsd_writer) return sim +# Openbabel and OpenMM def energy_minimize( compound, forcefield="UFF", From 2f5180a778a8c7cf496f643c1d47a1c881478904 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 21 Jul 2025 21:18:57 +0000 Subject: [PATCH 060/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.2 → v0.12.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.2...v0.12.4) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c52d970a4..a3677ff91 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.2 + rev: v0.12.4 hooks: # Run the linter. - id: ruff From d2265fd1ca95709f0233338441adab4696dc4cc9 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 22 Jul 2025 11:41:17 +0100 Subject: [PATCH 061/123] add Monomer class --- mbuild/polymer.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index b02026889..565169c7b 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -19,6 +19,11 @@ __all__ = ["Polymer"] +class Monomer(Compound): + def __init__(self): + pass + + class Polymer(Compound): """Connect one or more components in a specified sequence. From 627631b5674bc0b548120d728ad80d3685c36b45 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 22 Jul 2025 13:21:17 +0100 Subject: [PATCH 062/123] use more reasonable lengths for sidemax test --- mbuild/tests/test_packing.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/mbuild/tests/test_packing.py b/mbuild/tests/test_packing.py index 09d8137b7..2ecfa5183 100644 --- a/mbuild/tests/test_packing.py +++ b/mbuild/tests/test_packing.py @@ -575,9 +575,9 @@ def test_sidemax(self): ch4 = Methane() # With default sidemax - box_of_methane = mb.fill_box(ch4, box=[1000, 1000, 1000], n_compounds=500) + box_of_methane = mb.fill_box(ch4, box=[500, 500, 500], n_compounds=500) sphere_of_methane = mb.fill_sphere( - ch4, sphere=[1000, 1000, 1000, 1000], n_compounds=500 + ch4, sphere=[500, 500, 500, 500], n_compounds=500 ) assert all( np.asarray(box_of_methane.get_boundingbox().lengths) < [110, 110, 110] @@ -588,21 +588,21 @@ def test_sidemax(self): # With adjusted sidemax big_box_of_methane = mb.fill_box( - ch4, box=[1000, 1000, 1000], n_compounds=500, sidemax=1000.0 + ch4, box=[100, 100, 100], n_compounds=500, sidemax=200.0 ) big_sphere_of_methane = mb.fill_sphere( ch4, - sphere=[1000, 1000, 1000, 1000], + sphere=[100, 100, 100, 100], n_compounds=500, - sidemax=2000.0, + sidemax=200.0, ) assert all( - np.asarray(big_box_of_methane.get_boundingbox().lengths) > [900, 900, 900] + np.asarray(big_box_of_methane.get_boundingbox().lengths) > [90, 90, 90] ) assert all( np.asarray(big_sphere_of_methane.get_boundingbox().lengths) - > [1800, 1800, 1800] + > [180, 180, 180] ) def test_box_edge(self, h2o, methane): From 853aeaa35f448adfa42c51bd631e0c220fe91e2d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 22 Jul 2025 14:49:31 +0100 Subject: [PATCH 063/123] use smaller sidemax values in test --- mbuild/tests/test_packing.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/mbuild/tests/test_packing.py b/mbuild/tests/test_packing.py index 2ecfa5183..7f40510f7 100644 --- a/mbuild/tests/test_packing.py +++ b/mbuild/tests/test_packing.py @@ -575,34 +575,33 @@ def test_sidemax(self): ch4 = Methane() # With default sidemax - box_of_methane = mb.fill_box(ch4, box=[500, 500, 500], n_compounds=500) - sphere_of_methane = mb.fill_sphere( - ch4, sphere=[500, 500, 500, 500], n_compounds=500 + box_of_methane = mb.fill_box( + ch4, box=[20, 20, 20], n_compounds=500, sidemax=10.0 ) - assert all( - np.asarray(box_of_methane.get_boundingbox().lengths) < [110, 110, 110] + sphere_of_methane = mb.fill_sphere( + ch4, sphere=[20, 20, 20, 20], n_compounds=500, sidemax=10.0 ) + assert all(np.asarray(box_of_methane.get_boundingbox().lengths) < [11, 11, 11]) assert all( - np.asarray(sphere_of_methane.get_boundingbox().lengths) < [210, 210, 210] + np.asarray(sphere_of_methane.get_boundingbox().lengths) < [21, 21, 21] ) # With adjusted sidemax big_box_of_methane = mb.fill_box( - ch4, box=[100, 100, 100], n_compounds=500, sidemax=200.0 + ch4, box=[120, 120, 120], n_compounds=500, sidemax=20.0 ) big_sphere_of_methane = mb.fill_sphere( ch4, - sphere=[100, 100, 100, 100], + sphere=[120, 120, 120, 120], n_compounds=500, - sidemax=200.0, + sidemax=20.0, ) assert all( - np.asarray(big_box_of_methane.get_boundingbox().lengths) > [90, 90, 90] + np.asarray(big_box_of_methane.get_boundingbox().lengths) > [10, 10, 10] ) assert all( - np.asarray(big_sphere_of_methane.get_boundingbox().lengths) - > [180, 180, 180] + np.asarray(big_sphere_of_methane.get_boundingbox().lengths) > [14, 14, 14] ) def test_box_edge(self, h2o, methane): From 1be5b8a7b852b89facb4628f9f5df3533c24526b Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 23 Jul 2025 15:51:33 +0100 Subject: [PATCH 064/123] make hoomd sim class for better flexibility in designing sim methods --- mbuild/simulation.py | 252 +++++++++++++++++++++++++++---------------- 1 file changed, 161 insertions(+), 91 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 81093f735..42779a4b7 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -16,12 +16,152 @@ from mbuild.utils.io import import_ +class HoomdSimulation(hoomd.simulation.Simulation): + def __init__( + self, + compound, + forcefield, + r_cut, + run_on_gpu, + seed, + ): + if run_on_gpu: + try: + device = hoomd.device.GPU() + print(f"GPU found, running on device {device.device}") + except RuntimeError: + print( + "Unable to find compatible GPU device. ", + "set `run_on_gpu = False` or see HOOMD documentation " + "for further information about GPU support.", + ) + else: + device = hoomd.device.CPU() + self.compound = compound + self.forcefield = forcefield + self.r_cut = r_cut + self.snapshot, self.forces = self._to_hoomd_snap_forces() + self.active_forces = [] + self.inactive_forces = [] + super(HoomdSimulation, self).__init__(device=device, seed=seed) + self.create_state_from_snapshot(self.snapshot) + + def _to_hoomd_snap_forces(self): + # Convret to GMSO, apply forcefield + top = self.compound.to_gmso() + top.identify_connections() + apply(top, forcefields=self.forcefield) + # Get hoomd snapshot and force objects + forces, ref = gmso.external.to_hoomd_forcefield(top, r_cut=self.r_cut) + snap, ref = gmso.external.to_gsd_snapshot(top) + forces = list(set().union(*forces.values())) + return snap, forces + + def get_force(self, instance): + for force in set(self.forces + self.active_forces + self.inactive_forces): + if isinstance(force, instance): + return force + + def get_dpd_from_lj(self, A): + """Make a best-guess DPD force from types and parameters of an LJ force.""" + lj_force = self.get_force(hoomd.md.pair.LJ) + dpd = hoomd.md.pair.DPDConservative(nlist=lj_force.nlist) + for param in lj_force.params: + dpd.params[param] = dict(A=A) + dpd.r_cut[param] = lj_force.params[param]["sigma"] + return dpd + + def set_fire_integrator( + self, + dt, + force_tol, + angmom_tol, + energy_tol, + methods, + finc_dt=1.1, + fdec_dt=0.4, + alpha_start=0.2, + fdec_alpha=0.95, + min_steps_adapt=5, + min_steps_conv=20, + ): + fire = hoomd.md.minimize.FIRE( + dt=dt, + force_tol=force_tol, + angmom_tol=angmom_tol, + energy_tol=energy_tol, + finc_dt=finc_dt, + fdec_dt=fdec_dt, + alpha_start=alpha_start, + fdec_alpha=fdec_alpha, + min_steps_adapt=min_steps_adapt, + min_steps_conv=min_steps_conv, + methods=methods, + ) + fire.forces = self.active_forces + self.operations.integrator = fire + + def set_integrator(self, dt, method): + integrator = hoomd.md.Integrator(dt=dt) + integrator.forces = self.active_forces + integrator.methods = [method] + self.operations.integrator = integrator + + def add_gsd_writer(self, file_name, write_period): + """""" + pass + + ## HOOMD METHODS ## +def remove_overlaps_displacement_capped( + compound, + forcefield, + n_steps, + dt, + r_cut, + max_displacement, + run_on_gpu=False, + seed=42, +): + compound._kick() + sim = HoomdSimulation( + compound=compound, + forcefield=forcefield, + r_cut=r_cut, + run_on_gpu=run_on_gpu, + seed=seed, + ) + bond = sim.get_force(hoomd.md.bond.Harmonic) + angle = sim.get_force(hoomd.md.angle.Harmonic) + lj = sim.get_force(hoomd.md.pair.LJ) + # dpd = sim.get_dpd_from_lj(A=A_initial) + # Scale bond K and angle K + # for param in bond.params: + # bond.params[param]["k"] /= bond_k_scale + # for param in angle.params: + # angle.params[param]["k"] /= angle_k_scale + # Set up and run + sim.active_forces.extend([bond, angle, lj]) + displacement_capped = hoomd.md.methods.DisplacementCapped( + filter=hoomd.filter.All(), + maximum_displacement=max_displacement, + ) + sim.set_integrator(method=displacement_capped, dt=dt) + sim.run(n_steps) + with sim.state.cpu_local_snapshot as snap: + particles = snap.particles.rtag[:] + pos = snap.particles.position[particles] + compound.xyz = pos + + def remove_overlaps_fire( compound, forcefield, fire_iteration_steps, num_fire_iterations, + run_on_gpu, + seed=42, + r_cut=1.0, A_initial=10, bond_k_scale=100, angle_k_scale=100, @@ -30,8 +170,6 @@ def remove_overlaps_fire( angmom_tol=1e-2, energy_tol=1e-3, final_relaxation_steps=5000, - run_on_gpu=True, - seed=42, gsd_file=None, ): """Run a short HOOMD-Blue simulation with the FIRE integrator @@ -51,28 +189,30 @@ def remove_overlaps_fire( If true, attempts to run HOOMD-Blue on a GPU. If a GPU device isn't found, then it will run on the CPU """ - snap, forces = _compound_to_hoomd_snap_forces(compound, forcefield, r_cut=1.2) - lj, bond, angle = (None, None, None) - for force in forces: - if isinstance(force, hoomd.md.pair.LJ): - lj = force - if isinstance(force, hoomd.md.bond.Harmonic): - bond = force - elif isinstance(force, hoomd.md.angle.Harmonic): - angle = force - # Make DPD force, pulling info from LJ - dpd = hoomd.md.pair.DPDConservative(nlist=lj.nlist) - for param in lj.params: - dpd.params[param] = dict(A=A_initial) - dpd.r_cut[param] = lj.params[param]["sigma"] + compound._kick() + sim = HoomdSimulation( + compound=compound, + forcefield=forcefield, + r_cut=r_cut, + run_on_gpu=run_on_gpu, + seed=seed, + ) + bond = sim.get_force(hoomd.md.bond.Harmonic) + angle = sim.get_force(hoomd.md.angle.Harmonic) + lj = sim.get_force(hoomd.md.pair.LJ) + dpd = sim.get_dpd_from_lj(A=A_initial) # Scale bond K and angle K for param in bond.params: bond.params[param]["k"] /= bond_k_scale for param in angle.params: angle.params[param]["k"] /= angle_k_scale # Set up and run - nvt = hoomd.md.methods.ConstantVolume(filter=hoomd.filter.All()) - fire = hoomd.md.minimize.FIRE( + sim.active_forces.extend([bond, angle, dpd]) + displacement_capped = hoomd.md.methods.DisplacementCapped( + filter=hoomd.filter.All(), + maximum_displacement=1e-3, + ) + sim.set_fire_integrator( dt=dt, force_tol=force_tol, angmom_tol=angmom_tol, @@ -83,19 +223,13 @@ def remove_overlaps_fire( fdec_alpha=0.95, min_steps_adapt=5, min_steps_conv=20, - methods=[nvt], - ) - sim = _hoomd_fire_sim( - snapshot=snap, - forces=[dpd, bond, angle], - fire=fire, - run_on_gpu=run_on_gpu, - seed=seed, - gsd_file=gsd_file, + methods=[displacement_capped], ) # Run FIRE sims with DPD + scaled bonds and angles for sim_num in range(num_fire_iterations): sim.run(fire_iteration_steps) + # while not (sim.operations.integrator.converged): + # sim.run(200) # Re-scale bonds and angle force constants, Run DPD sim again for param in bond.params: bond.params[param]["k"] *= bond_k_scale @@ -113,70 +247,6 @@ def remove_overlaps_fire( compound.xyz = pos -def _compound_to_hoomd_snap_forces(compound, forcefield, r_cut): - # Convret to GMSO, apply forcefield - top = compound.to_gmso() - top.identify_connections() - apply(top, forcefields=forcefield) - # Get hoomd snapshot and force objects - forces, ref = gmso.external.to_hoomd_forcefield(top, r_cut=r_cut) - snap, ref = gmso.external.to_gsd_snapshot(top) - forces = list(set().union(*forces.values())) - return snap, forces - - -def _hoomd_fire_sim(snapshot, forces, run_on_gpu, seed, fire, gsd_file): - sim = _create_hoomd_simulation(snapshot, forces, run_on_gpu, seed, gsd_file) - fire.forces = forces - sim.operations.integrator = fire - return sim - - -def _hoomd_nvt_sim(snapshot, forces, run_on_gpu, seed, gsd_file): - sim = _create_hoomd_simulation(snapshot, forces, run_on_gpu, seed, gsd_file) - nvt = hoomd.md.methods.ConstantVolume(filter=hoomd.filter.All()) - integrator = hoomd.md.Integrator(dt=0.0001) - integrator.forces = forces - integrator.methods = [nvt] - sim.operations.integrator = integrator - return sim - - -def _hoomd_box_update(snapshot, forces, run_on_gpu, seed, gsd_file, target_box): - """Run a quick compression to expansion on an mBuild Compound.""" - sim = _create_hoomd_simulation(snapshot, forces, run_on_gpu, seed, gsd_file) - nvt = hoomd.md.methods.ConstantVolume(filter=hoomd.filter.All()) - integrator = hoomd.md.Integrator(dt=0.0001) - integrator.forces = forces - integrator.methods = [nvt] - sim.operations.integrator = integrator - return sim - - -def _create_hoomd_simulation(snapshot, forces, run_on_gpu, seed, gsd_file): - # Set up common hoomd stuff here - if run_on_gpu: - try: - device = hoomd.device.GPU() - print(f"GPU found, running on device {device.device}") - except RuntimeError: - print("GPU not found, running on CPU anyway.") - device = hoomd.device.CPU() - else: - device = hoomd.device.CPU() - sim = hoomd.Simulation(device=device, seed=seed) - sim.create_state_from_snapshot(snapshot) - if gsd_file: - gsd_writer = hoomd.write.GSD( - filename=gsd_file, - trigger=hoomd.trigger.Periodic(1000), - mode="wb", - filter=hoomd.filter.All(), - ) - sim.operations.writers.append(gsd_writer) - return sim - - # Openbabel and OpenMM def energy_minimize( compound, From 40da13ae8efec26ca881074de88d3b4f6ab8b988 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 24 Jul 2025 14:02:13 +0100 Subject: [PATCH 065/123] continue reworking sim class and methods --- mbuild/compound.py | 17 +++++++++++++++ mbuild/polymer.py | 3 ++- mbuild/simulation.py | 52 ++++++++++++++++++++++++++++++++++++++------ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 568f09faf..959c390d1 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -197,6 +197,8 @@ def __init__( self._charge = charge self._mass = mass + self._hoomd_data = {} + def particles(self, include_ports=False): """Return all Particles of the Compound. @@ -2716,6 +2718,21 @@ def _clone_bonds(self, clone_of=None): "Particles outside of its containment hierarchy." ) + def _add_sim_data(self, state, forces, forcefield): + self._hoomd_data["state"] = state + self._hoomd_data["force_field"] = forcefield + self._hoomd_data["forces"] = forces + + def _get_sim_data(self): + if not self._hoomd_data: + return None, None, None + else: + return ( + self._hoomd_data["state"], + self._hoomd_data["forces"], + self._hoomd_data["forcefield"], + ) + Particle = Compound diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 565169c7b..809e1cddf 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -14,6 +14,7 @@ ) from mbuild.lib.atoms import H from mbuild.port import Port +from mbuild.simulation import energy_minimize as e_min from mbuild.utils.validation import assert_port_exists __all__ = ["Polymer"] @@ -152,7 +153,7 @@ def set_monomer_positions(self, coordinates, energy_minimize=True): for i, xyz in enumerate(coordinates): self.children[i].translate_to(xyz) if energy_minimize: - self.energy_minimize() + e_min(self) def straighten(self, axis=(1, 0, 0), energy_minimize=True): """Shift monomer positions so that the backbone is straight. diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 42779a4b7..34e612712 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -40,7 +40,25 @@ def __init__( self.compound = compound self.forcefield = forcefield self.r_cut = r_cut - self.snapshot, self.forces = self._to_hoomd_snap_forces() + # Check if a sim method has been used on this compound already + if compound._hoomd_data: + last_snapshot, last_forces, last_forcefield = compound._get_sim_data() + # Check if the forcefield passed this time matches last time + if forcefield == last_forcefield: + self.snapshot = last_snapshot + self.forces = last_forces + self.forcefield = last_forcefield + else: # New foyer/gmso forcefield has been passed, reapply + self.snapshot, self.forces = self._to_hoomd_snap_forces() + compound._add_sim_data( + state=self.snapshot, forces=self.forces, forcefield=self.forcefield + ) + else: # Sim method not used on this compound previously + self.snapshot, self.forces = self._to_hoomd_snap_forces() + compound._add_sim_data( + state=self.snapshot, forces=self.forces, forcefield=self.forcefield + ) + # Place holders for forces added/changed by specific methods below self.active_forces = [] self.inactive_forces = [] super(HoomdSimulation, self).__init__(device=device, seed=seed) @@ -111,16 +129,27 @@ def add_gsd_writer(self, file_name, write_period): """""" pass + def update_positions(self): + """Update compound positions from snapshot.""" + with self.state.cpu_local_snapshot as snap: + particles = snap.particles.rtag[:] + pos = snap.particles.position[particles] + self.compound.xyz = pos + ## HOOMD METHODS ## def remove_overlaps_displacement_capped( compound, forcefield, n_steps, + n_relax_steps, dt, r_cut, max_displacement, + dpd_A, run_on_gpu=False, + bond_scale=1, + angle_scale=1, seed=42, ): compound._kick() @@ -134,20 +163,29 @@ def remove_overlaps_displacement_capped( bond = sim.get_force(hoomd.md.bond.Harmonic) angle = sim.get_force(hoomd.md.angle.Harmonic) lj = sim.get_force(hoomd.md.pair.LJ) - # dpd = sim.get_dpd_from_lj(A=A_initial) + dpd = sim.get_dpd_from_lj(A=dpd_A) # Scale bond K and angle K - # for param in bond.params: - # bond.params[param]["k"] /= bond_k_scale - # for param in angle.params: - # angle.params[param]["k"] /= angle_k_scale + for param in bond.params: + bond.params[param]["k"] /= bond_scale + for param in angle.params: + angle.params[param]["k"] /= angle_scale # Set up and run - sim.active_forces.extend([bond, angle, lj]) + sim.active_forces.extend([bond, angle, dpd]) displacement_capped = hoomd.md.methods.DisplacementCapped( filter=hoomd.filter.All(), maximum_displacement=max_displacement, ) sim.set_integrator(method=displacement_capped, dt=dt) sim.run(n_steps) + # Run with LJ pairs, original bonds and angles + if n_relax_steps > 0: + sim.operations.integrator.forces.remove(dpd) + sim.operations.integrator.forces.append(lj) + for param in bond.params: + bond.params[param]["k"] *= bond_scale + for param in angle.params: + angle.params[param]["k"] *= angle_scale + sim.run(n_relax_steps) with sim.state.cpu_local_snapshot as snap: particles = snap.particles.rtag[:] pos = snap.particles.position[particles] From 478501c31fe90c7f6108974493aa267f2df24380 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 25 Jul 2025 15:38:40 +0100 Subject: [PATCH 066/123] Add logic to start RW from another Path Object --- mbuild/path.py | 80 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 65 insertions(+), 15 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index ed5e8e560..a7a362b06 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -80,11 +80,19 @@ def __init__(self, N=None, coordinates=None, bond_graph=None): else: raise ValueError("Specify either one of N and coordinates, or neither") self.generate() + if self.N is None: + self.N = len(self.coordinates) @classmethod def from_coordinates(cls, coordinates, bond_graph=None): return cls(coordinates=coordinates, bond_graph=bond_graph, N=None) + def _extend_coordinates(self, N): + zeros = np.zeros((N, 3)) + new_array = np.concatenate(self.coordinates, zeros) + self.coordinates = new_array + self.N += N + @abstractmethod def generate(self): """Abstract class for running a Path generation algorithm @@ -123,7 +131,7 @@ def neighbor_list(self, r_max, coordinates=None, box=None): nlist = aq_query.toNeighborList() return nlist - def to_compound(self, bead_name="Bead", bead_mass=1): + def to_compound(self, bead_name="_A", bead_mass=1): """Visualize a path as an mBuild Compound""" compound = Compound() for xyz in self.coordinates: @@ -160,6 +168,8 @@ def __init__( max_angle, max_attempts, seed, + start_from_path=None, + start_from_path_index=None, tolerance=1e-5, bond_graph=None, ): @@ -197,23 +207,63 @@ def __init__( self.tolerance = tolerance self.max_attempts = max_attempts self.attempts = 0 - self.count = 0 - super(HardSphereRandomWalk, self).__init__(N=N, bond_graph=bond_graph) + self.start_from_path_index = start_from_path_index + self.start_from_path = start_from_path + if start_from_path and start_from_path_index is not None: + coordinates = np.concatenate( + (np.copy(start_from_path.coordinates), np.zeros((N, 3))), axis=0 + ) + self.count = len(start_from_path.coordinates) - 1 + N = None + else: + self.count = 0 + self.start_index = 0 + coordinates = None + + super(HardSphereRandomWalk, self).__init__( + coordinates=coordinates, N=N, bond_graph=bond_graph + ) def generate(self): np.random.seed(self.seed) - # With fixed bond lengths, the first move is always accepted - phi = np.random.uniform(0, 2 * np.pi) - theta = np.random.uniform(0, np.pi) - next_pos = np.array( - [ - self.bond_length * np.sin(theta) * np.cos(phi), - self.bond_length * np.sin(theta) * np.sin(phi), - self.bond_length * np.cos(theta), - ] - ) - self.coordinates[1] = next_pos - self.count += 1 # We already have 1 accepted move + if not self.start_from_path: + # With fixed bond lengths, first move is always accepted + phi = np.random.uniform(0, 2 * np.pi) + theta = np.random.uniform(0, np.pi) + next_pos = np.array( + [ + self.bond_length * np.sin(theta) * np.cos(phi), + self.bond_length * np.sin(theta) * np.sin(phi), + self.bond_length * np.cos(theta), + ] + ) + self.coordinates[1] = next_pos + self.count += 1 # We already have 1 accepted move + else: + # Start a while loop here + started_next_path = False + while not started_next_path: + next_pos = self._next_coordinate( + pos1=self.start_from_path.coordinates[self.start_from_path_index], + pos2=self.start_from_path.coordinates[ + self.start_from_path_index - 1 + ], + ) + self.coordinates[self.count + 1] = next_pos + if self._check_path(): + self.count += 1 + self.attempts += 1 + started_next_path = True + else: + self.coordinates[self.count + 1] = np.zeros(3) + + if self.attempts == self.max_attempts and self.count < self.N: + raise RuntimeError( + "The maximum number attempts allowed have passed, and only ", + f"{self.count} sucsessful attempts were completed.", + "Try changing the parameters and running again.", + ) + while self.count < self.N - 1: new_xyz = self._next_coordinate( pos1=self.coordinates[self.count], From 605e45dbf85ea7050fb99db4d1984d8925f8ef41 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 28 Jul 2025 16:43:58 +0100 Subject: [PATCH 067/123] Bump to version 1.2.1 --- docs/conf.py | 4 ++-- mbuild/__init__.py | 2 +- setup.cfg | 10 +++++----- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index df60e2c60..0c72b907c 100755 --- a/docs/conf.py +++ b/docs/conf.py @@ -158,8 +158,8 @@ # built documents. # -version = "1.2.0" -release = "1.2.0" +version = "1.2.1" +release = "1.2.1" # The language for content autogenerated by Sphinx. Refer to documentation diff --git a/mbuild/__init__.py b/mbuild/__init__.py index 10f4cc06e..044df7e69 100644 --- a/mbuild/__init__.py +++ b/mbuild/__init__.py @@ -13,5 +13,5 @@ from mbuild.port import Port from mbuild.recipes import recipes -__version__ = "1.2.0" +__version__ = "1.2.1" __date__ = "2025-01-23" diff --git a/setup.cfg b/setup.cfg index 2d1c3039d..5b2fb6c8c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,24 +1,24 @@ [bumpversion] -current_version = 1.2.0 +current_version = 1.2.1 commit = True tag = True message = Bump to version {new_version} tag_name = {new_version} [coverage:run] -omit = +omit = mbuild/examples/* mbuild/tests/* [coverage:report] -exclude_lines = +exclude_lines = pragma: no cover - + if 0: if __name__ == .__main__.: def __repr__ except ImportError -omit = +omit = mbuild/examples/* mbuild/tests/* From 2bf33a452452857cd95ec54727c25fa2dfb186d3 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 28 Jul 2025 16:49:43 +0100 Subject: [PATCH 068/123] precommit fix for setup.cfg --- setup.cfg | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.cfg b/setup.cfg index 5b2fb6c8c..35e3d25a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -6,19 +6,19 @@ message = Bump to version {new_version} tag_name = {new_version} [coverage:run] -omit = +omit = mbuild/examples/* mbuild/tests/* [coverage:report] -exclude_lines = +exclude_lines = pragma: no cover - + if 0: if __name__ == .__main__.: def __repr__ except ImportError -omit = +omit = mbuild/examples/* mbuild/tests/* From 21f916261b4fce119abebfc3a446a41c7b91bd09 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Jul 2025 21:09:49 +0000 Subject: [PATCH 069/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.4 → v0.12.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.4...v0.12.5) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a3677ff91..cc0f42566 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.4 + rev: v0.12.5 hooks: # Run the linter. - id: ruff From fba390e828953d90fede84b8846dc73979215ba4 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 1 Aug 2025 16:45:35 +0100 Subject: [PATCH 070/123] set dtype based on tolerance --- mbuild/path.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index a7a362b06..6ea208bf2 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -199,6 +199,10 @@ def __init__( bond_graph : networkx.graph.Graph; optional Sets the bonding of sites along the path. """ + if tolerance < 1e-6: + self.dtype = np.float64 + else: + self.dtype = np.float32 self.bond_length = bond_length self.radius = radius self.min_angle = min_angle @@ -211,17 +215,21 @@ def __init__( self.start_from_path = start_from_path if start_from_path and start_from_path_index is not None: coordinates = np.concatenate( - (np.copy(start_from_path.coordinates), np.zeros((N, 3))), axis=0 + ( + np.copy(start_from_path.coordinates).astype(self.dtype), + np.zeros((N, 3), dtype=self.dtype), + ), + axis=0, ) self.count = len(start_from_path.coordinates) - 1 N = None else: + coordinates = np.zeros((N, 3), dtype=self.dtype) self.count = 0 self.start_index = 0 - coordinates = None super(HardSphereRandomWalk, self).__init__( - coordinates=coordinates, N=N, bond_graph=bond_graph + coordinates=coordinates, N=None, bond_graph=bond_graph ) def generate(self): @@ -289,7 +297,7 @@ def _next_coordinate(self, pos1, pos2): v1_norm = v1 / np.linalg.norm(v1) theta = np.random.uniform(self.min_angle, self.max_angle) # Pick random vector and center around origin (0,0,0) - r = np.random.rand(3) - 0.5 + r = (np.random.rand(3) - 0.5).astype(self.dtype) r_perp = r - np.dot(r, v1_norm) * v1_norm r_perp_norm = r_perp / np.linalg.norm(r_perp) # New vector, rotated relative to v1 From 37f3b2c21745799858b71175b1ce80290aedad2c Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 1 Aug 2025 23:24:23 +0100 Subject: [PATCH 071/123] use numpy for pair dist calculations instead of freud --- mbuild/path.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index 6ea208bf2..c4c76318c 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -306,20 +306,12 @@ def _next_coordinate(self, pos1, pos2): return pos1 + next_pos def _check_path(self): - """Use neighbor_list to check for pairs within a distance smaller than the radius""" - # Grow box size as number of steps grows - box_length = self.count * self.radius * 2.01 - # Only need neighbor list for accepted moves + current trial move - coordinates = self.coordinates[: self.count + 2] - nlist = self.neighbor_list( - coordinates=coordinates, - r_max=self.radius - self.tolerance, - box=[box_length, box_length, box_length], - ) - if len(nlist.distances) > 0: # Particle pairs found within the particle radius - return False - else: - return True + """Check new trial point against previous ones only.""" + new_point = self.coordinates[self.count + 1] + existing_points = self.coordinates[: self.count + 1] + sq_dists = np.sum((existing_points - new_point) ** 2, axis=1) + min_sq_dist = (self.radius - self.tolerance) ** 2 + return not np.any(sq_dists < min_sq_dist) class Lamellar(Path): From eda77c876330063e0baa77bde6fb22ab8fd42796 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sat, 2 Aug 2025 10:50:27 +0100 Subject: [PATCH 072/123] use trial batch approach for initial pos from another path --- mbuild/path.py | 68 +++++++++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 25 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index c4c76318c..6162ed13d 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -168,6 +168,7 @@ def __init__( max_angle, max_attempts, seed, + trial_batch_size=5, start_from_path=None, start_from_path_index=None, tolerance=1e-5, @@ -209,11 +210,13 @@ def __init__( self.max_angle = max_angle self.seed = seed self.tolerance = tolerance + self.trial_batch_size = trial_batch_size self.max_attempts = max_attempts self.attempts = 0 self.start_from_path_index = start_from_path_index self.start_from_path = start_from_path if start_from_path and start_from_path_index is not None: + # TODO: Do we need np.copy here? coordinates = np.concatenate( ( np.copy(start_from_path.coordinates).astype(self.dtype), @@ -251,19 +254,23 @@ def generate(self): # Start a while loop here started_next_path = False while not started_next_path: - next_pos = self._next_coordinate( + new_xyzs = self._next_coordinate( pos1=self.start_from_path.coordinates[self.start_from_path_index], pos2=self.start_from_path.coordinates[ self.start_from_path_index - 1 ], ) - self.coordinates[self.count + 1] = next_pos - if self._check_path(): - self.count += 1 + new_xyz_found = False + for xyz in new_xyzs: + if self._check_path(xyz): + self.coordinates[self.count + 1] = xyz + self.count += 1 + self.attempts += 1 + new_xyz_found = True + started_next_path = True + break + if not new_xyz_found: self.attempts += 1 - started_next_path = True - else: - self.coordinates[self.count + 1] = np.zeros(3) if self.attempts == self.max_attempts and self.count < self.N: raise RuntimeError( @@ -273,17 +280,22 @@ def generate(self): ) while self.count < self.N - 1: - new_xyz = self._next_coordinate( + new_xyzs = self._next_coordinate( pos1=self.coordinates[self.count], pos2=self.coordinates[self.count - 1], ) - self.coordinates[self.count + 1] = new_xyz - if self._check_path(): - self.count += 1 - self.attempts += 1 - else: - self.coordinates[self.count + 1] = np.zeros(3) + new_xyz_found = False + for xyz in new_xyzs: + if self._check_path(new_point=xyz): + self.coordinates[self.count + 1] = xyz + self.count += 1 + self.attempts += 1 + new_xyz_found = True + break + if not new_xyz_found: + # self.coordinates[self.count + 1] = np.zeros(3) self.attempts += 1 + if self.attempts == self.max_attempts and self.count < self.N: raise RuntimeError( "The maximum number attempts allowed have passed, and only ", @@ -295,19 +307,25 @@ def _next_coordinate(self, pos1, pos2): # Vector formed by previous 2 coordinates v1 = pos2 - pos1 v1_norm = v1 / np.linalg.norm(v1) - theta = np.random.uniform(self.min_angle, self.max_angle) - # Pick random vector and center around origin (0,0,0) - r = (np.random.rand(3) - 0.5).astype(self.dtype) - r_perp = r - np.dot(r, v1_norm) * v1_norm - r_perp_norm = r_perp / np.linalg.norm(r_perp) - # New vector, rotated relative to v1 - v2 = np.cos(theta) * v1_norm + np.sin(theta) * r_perp_norm - next_pos = v2 * self.bond_length - return pos1 + next_pos + # Generate batch of random angles + thetas = np.random.uniform( + self.min_angle, self.max_angle, size=self.trial_batch_size + ).astype(self.dtype) + # Batch of random vectors and center around origin (0,0,0) + r = (np.random.rand(self.trial_batch_size, 3) - 0.5).astype(self.dtype) + dot_products = np.dot(r, v1_norm) + r_perp = r - dot_products[:, np.newaxis] * v1_norm + norms = np.linalg.norm(r_perp, axis=1) + r_perp_norm = r_perp / norms[:, np.newaxis] + v2s = ( + np.cos(thetas)[:, np.newaxis] * v1_norm + + np.sin(thetas)[:, np.newaxis] * r_perp_norm + ) + next_positions = pos1 + v2s * self.bond_length + return next_positions - def _check_path(self): + def _check_path(self, new_point): """Check new trial point against previous ones only.""" - new_point = self.coordinates[self.count + 1] existing_points = self.coordinates[: self.count + 1] sq_dists = np.sum((existing_points - new_point) ** 2, axis=1) min_sq_dist = (self.radius - self.tolerance) ** 2 From 3359a380af7352aa6ea98e3b9062155fa1cfdaa9 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sat, 2 Aug 2025 20:55:25 +0100 Subject: [PATCH 073/123] Add numba methods --- mbuild/path.py | 206 ++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 162 insertions(+), 44 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index 6162ed13d..d70143ead 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -58,6 +58,13 @@ class that others can inherit from then implement their own approach? Make coordinates a property with a setter? Keep as a plain attribute? """ +try: + from numba import njit + + _NUMBA_AVAILABLE = True +except ImportError: + _NUMBA_AVAILABLE = False + class Path: def __init__(self, N=None, coordinates=None, bond_graph=None): @@ -172,6 +179,7 @@ def __init__( start_from_path=None, start_from_path_index=None, tolerance=1e-5, + use_numba=False, bond_graph=None, ): """Generates coordinates from a self avoiding random walk using @@ -195,23 +203,19 @@ def __init__( for the next step. seed : int, default = 42 Random seed - tolerance : float, default = 1e-5 + tolerance : float, default = 1e-4 Tolerance used for rounding. bond_graph : networkx.graph.Graph; optional Sets the bonding of sites along the path. """ - if tolerance < 1e-6: - self.dtype = np.float64 - else: - self.dtype = np.float32 self.bond_length = bond_length self.radius = radius self.min_angle = min_angle self.max_angle = max_angle - self.seed = seed + self.seed = int(seed) self.tolerance = tolerance - self.trial_batch_size = trial_batch_size - self.max_attempts = max_attempts + self.trial_batch_size = int(trial_batch_size) + self.max_attempts = int(max_attempts) self.attempts = 0 self.start_from_path_index = start_from_path_index self.start_from_path = start_from_path @@ -219,22 +223,33 @@ def __init__( # TODO: Do we need np.copy here? coordinates = np.concatenate( ( - np.copy(start_from_path.coordinates).astype(self.dtype), - np.zeros((N, 3), dtype=self.dtype), + np.copy(start_from_path.coordinates).astype(np.float32), + np.zeros((N, 3), dtype=np.float32), ), axis=0, ) self.count = len(start_from_path.coordinates) - 1 N = None else: - coordinates = np.zeros((N, 3), dtype=self.dtype) + coordinates = np.zeros((N, 3), dtype=np.float32) self.count = 0 self.start_index = 0 + if not use_numba: + self.next_coordinate = _random_coordinate_numpy + self.check_path = _check_path_numpy + elif use_numba and _NUMBA_AVAILABLE: + self.next_coordinate = _random_coordinate_numba + self.check_path = _check_path_numba + elif use_numba and not _NUMBA_AVAILABLE: + raise RuntimeError( + "numba was not found. Set `use_numba` to `False` to add numba to your environment." + ) super(HardSphereRandomWalk, self).__init__( coordinates=coordinates, N=None, bond_graph=bond_graph ) + # Get methods to use for random walk def generate(self): np.random.seed(self.seed) if not self.start_from_path: @@ -254,15 +269,24 @@ def generate(self): # Start a while loop here started_next_path = False while not started_next_path: - new_xyzs = self._next_coordinate( + new_xyzs = self.next_coordinate( pos1=self.start_from_path.coordinates[self.start_from_path_index], pos2=self.start_from_path.coordinates[ self.start_from_path_index - 1 ], + bond_length=self.bond_length, + min_angle=self.min_angle, + max_angle=self.max_angle, + batch_size=self.trial_batch_size, ) new_xyz_found = False for xyz in new_xyzs: - if self._check_path(xyz): + if self.check_path( + existing_points=self.coordinates[: self.count + 1], + new_point=xyz, + radius=self.radius, + tolerance=self.tolerance, + ): self.coordinates[self.count + 1] = xyz self.count += 1 self.attempts += 1 @@ -280,20 +304,28 @@ def generate(self): ) while self.count < self.N - 1: - new_xyzs = self._next_coordinate( + new_xyzs = self.next_coordinate( pos1=self.coordinates[self.count], pos2=self.coordinates[self.count - 1], + bond_length=self.bond_length, + min_angle=self.min_angle, + max_angle=self.max_angle, + batch_size=self.trial_batch_size, ) new_xyz_found = False for xyz in new_xyzs: - if self._check_path(new_point=xyz): + if self.check_path( + existing_points=self.coordinates[: self.count + 1], + new_point=xyz, + radius=self.radius, + tolerance=self.tolerance, + ): self.coordinates[self.count + 1] = xyz self.count += 1 self.attempts += 1 new_xyz_found = True break if not new_xyz_found: - # self.coordinates[self.count + 1] = np.zeros(3) self.attempts += 1 if self.attempts == self.max_attempts and self.count < self.N: @@ -303,34 +335,6 @@ def generate(self): "Try changing the parameters and running again.", ) - def _next_coordinate(self, pos1, pos2): - # Vector formed by previous 2 coordinates - v1 = pos2 - pos1 - v1_norm = v1 / np.linalg.norm(v1) - # Generate batch of random angles - thetas = np.random.uniform( - self.min_angle, self.max_angle, size=self.trial_batch_size - ).astype(self.dtype) - # Batch of random vectors and center around origin (0,0,0) - r = (np.random.rand(self.trial_batch_size, 3) - 0.5).astype(self.dtype) - dot_products = np.dot(r, v1_norm) - r_perp = r - dot_products[:, np.newaxis] * v1_norm - norms = np.linalg.norm(r_perp, axis=1) - r_perp_norm = r_perp / norms[:, np.newaxis] - v2s = ( - np.cos(thetas)[:, np.newaxis] * v1_norm - + np.sin(thetas)[:, np.newaxis] * r_perp_norm - ) - next_positions = pos1 + v2s * self.bond_length - return next_positions - - def _check_path(self, new_point): - """Check new trial point against previous ones only.""" - existing_points = self.coordinates[: self.count + 1] - sq_dists = np.sum((existing_points - new_point) ** 2, axis=1) - min_sq_dist = (self.radius - self.tolerance) ** 2 - return not np.any(sq_dists < min_sq_dist) - class Lamellar(Path): def __init__( @@ -655,3 +659,117 @@ def generate(self): self.coordinates[i] = (x2d, 0, y2d) elif self.plane == "yz": self.coordinates[i] = (0, x2d, y2d) + + +# Internal helper/utility methods below: + + +def _random_coordinate_numpy(pos1, pos2, bond_length, min_angle, max_angle, batch_size): + # Vector formed by previous 2 coordinates + v1 = pos2 - pos1 + v1_norm = v1 / np.linalg.norm(v1) + # Generate batch of random angles + thetas = np.random.uniform(min_angle, max_angle, size=batch_size) + # Batch of random vectors and center around origin (0,0,0) + r = np.random.rand(batch_size, 3) - 0.5 + dot_products = np.dot(r, v1_norm) + r_perp = r - dot_products[:, np.newaxis] * v1_norm + norms = np.linalg.norm(r_perp, axis=1) + for norm in norms: + if norm < 1e-6: + norm = 1.0 + r_perp_norm = r_perp / norms[:, np.newaxis] + v2s = ( + np.cos(thetas)[:, np.newaxis] * v1_norm + + np.sin(thetas)[:, np.newaxis] * r_perp_norm + ) + next_positions = pos1 + v2s * bond_length + return next_positions + + +def _check_path_numpy(existing_points, new_point, radius, tolerance): + """Check new trial point against previous ones only.""" + sq_dists = np.sum((existing_points - new_point) ** 2, axis=1) + min_sq_dist = (radius - tolerance) ** 2 + return not np.any(sq_dists < min_sq_dist) + + +@njit(fastmath=True) +def norm(vec): + s = 0.0 + for i in range(vec.shape[0]): + s += vec[i] * vec[i] + return np.sqrt(s) + + +@njit(fastmath=True) +def _random_coordinate_numba( + pos1, + pos2, + bond_length, + min_angle, + max_angle, + batch_size, +): + v1 = pos2 - pos1 + v1_norm = v1 / norm(v1) + + thetas = np.empty(batch_size, dtype=np.float32) + for i in range(batch_size): + thetas[i] = min_angle + (max_angle - min_angle) * np.random.random() + + r = np.empty((batch_size, 3), dtype=np.float32) + for i in range(batch_size): + for j in range(3): + r[i, j] = np.random.random() - 0.5 + + dot_products = np.empty(batch_size, dtype=np.float32) + for i in range(batch_size): + dot = 0.0 + for j in range(3): + dot += r[i, j] * v1_norm[j] + dot_products[i] = dot + + r_perp = np.empty((batch_size, 3), dtype=np.float32) + for i in range(batch_size): + for j in range(3): + r_perp[i, j] = r[i, j] - dot_products[i] * v1_norm[j] + + norms = np.empty(batch_size, dtype=np.float32) + for i in range(batch_size): + norms[i] = norm(r_perp[i]) + + for i in range(batch_size): + if norms[i] < 1e-6: + norms[i] = 1.0 + + r_perp_norm = np.empty((batch_size, 3), dtype=np.float32) + for i in range(batch_size): + for j in range(3): + r_perp_norm[i, j] = r_perp[i, j] / norms[i] + + v2s = np.empty((batch_size, 3), dtype=np.float32) + for i in range(batch_size): + cos_theta = np.cos(thetas[i]) + sin_theta = np.sin(thetas[i]) + for j in range(3): + v2s[i, j] = cos_theta * v1_norm[j] + sin_theta * r_perp_norm[i, j] + + next_positions = np.empty((batch_size, 3), dtype=np.float32) + for i in range(batch_size): + for j in range(3): + next_positions[i, j] = pos1[j] + v2s[i, j] * bond_length + return next_positions + + +@njit +def _check_path_numba(existing_points, new_point, radius, tolerance): + min_sq_dist = (radius - tolerance) ** 2 + for i in range(existing_points.shape[0]): + dist_sq = 0.0 + for j in range(existing_points.shape[1]): + diff = existing_points[i, j] - new_point[j] + dist_sq += diff * diff + if dist_sq < min_sq_dist: + return False + return True From e08d22ec8a05108a021faf8e8dfac5a1b996e74b Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sat, 2 Aug 2025 22:57:59 +0100 Subject: [PATCH 074/123] A couple of fixes --- mbuild/path.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index d70143ead..a70c0bb54 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -187,7 +187,7 @@ def __init__( formed by 3 consecutive points. Possible angle values are sampled uniformly between min_angle - and max_angle between a new site and the two previous sites. + and max_angle between the new site and the two previous sites. Parameters: ----------- @@ -212,7 +212,7 @@ def __init__( self.radius = radius self.min_angle = min_angle self.max_angle = max_angle - self.seed = int(seed) + self.seed = seed self.tolerance = tolerance self.trial_batch_size = int(trial_batch_size) self.max_attempts = int(max_attempts) @@ -234,6 +234,7 @@ def __init__( coordinates = np.zeros((N, 3), dtype=np.float32) self.count = 0 self.start_index = 0 + # Get methods to use for random walk if not use_numba: self.next_coordinate = _random_coordinate_numpy self.check_path = _check_path_numpy @@ -242,14 +243,13 @@ def __init__( self.check_path = _check_path_numba elif use_numba and not _NUMBA_AVAILABLE: raise RuntimeError( - "numba was not found. Set `use_numba` to `False` to add numba to your environment." + "numba was not found. Set `use_numba` to `False` or add numba to your environment." ) super(HardSphereRandomWalk, self).__init__( coordinates=coordinates, N=None, bond_graph=bond_graph ) - # Get methods to use for random walk def generate(self): np.random.seed(self.seed) if not self.start_from_path: @@ -312,7 +312,6 @@ def generate(self): max_angle=self.max_angle, batch_size=self.trial_batch_size, ) - new_xyz_found = False for xyz in new_xyzs: if self.check_path( existing_points=self.coordinates[: self.count + 1], @@ -322,11 +321,8 @@ def generate(self): ): self.coordinates[self.count + 1] = xyz self.count += 1 - self.attempts += 1 - new_xyz_found = True break - if not new_xyz_found: - self.attempts += 1 + self.attempts += 1 if self.attempts == self.max_attempts and self.count < self.N: raise RuntimeError( From 6bdd76e68de63c5833aa6b3ed8b4f6b15d282cc6 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sun, 3 Aug 2025 10:40:03 +0100 Subject: [PATCH 075/123] update doc stirngs, remove unused class methods --- mbuild/path.py | 39 +++++++++------------------------------ 1 file changed, 9 insertions(+), 30 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index a70c0bb54..fc802ab5f 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -113,16 +113,6 @@ def generate(self): """ pass - @abstractmethod - def _next_coordinate(self): - """Algorithm to generate the next coordinate in the path""" - pass - - @abstractmethod - def _check_path(self): - """Algorithm to accept/reject trial move of the current path""" - pass - def neighbor_list(self, r_max, coordinates=None, box=None): """Use freud to create a neighbor list of a set of coordinates.""" if coordinates is None: @@ -234,6 +224,8 @@ def __init__( coordinates = np.zeros((N, 3), dtype=np.float32) self.count = 0 self.start_index = 0 + # Need this for error message about reaching max tries + self._init_count = self.count # Get methods to use for random walk if not use_numba: self.next_coordinate = _random_coordinate_numpy @@ -265,7 +257,8 @@ def generate(self): ) self.coordinates[1] = next_pos self.count += 1 # We already have 1 accepted move - else: + self.cound_adj = 0 + else: # Start random walk from a previous set of coordinates # Start a while loop here started_next_path = False while not started_next_path: @@ -299,7 +292,7 @@ def generate(self): if self.attempts == self.max_attempts and self.count < self.N: raise RuntimeError( "The maximum number attempts allowed have passed, and only ", - f"{self.count} sucsessful attempts were completed.", + f"{self.count - self._init_count} sucsessful attempts were completed.", "Try changing the parameters and running again.", ) @@ -424,14 +417,10 @@ def generate(self): self.coordinates.extend(arc[::-1]) self.coordinates.extend(list(this_stack)) - def _next_coordinate(self): - pass - - def _check_path(self): - pass - class StraightLine(Path): + """Generates a set of coordinates in a straight line along a given axis.""" + def __init__(self, spacing, N, direction=(1, 0, 0), bond_graph=None): self.spacing = spacing self.direction = np.asarray(direction) @@ -442,14 +431,10 @@ def generate(self): [np.zeros(3) + i * self.spacing * self.direction for i in range(self.N)] ) - def _next_coordinate(self): - pass - - def _check_path(self): - pass - class Cyclic(Path): + """Generates a set of coordinates evenly spaced along a circle.""" + def __init__(self, spacing=None, N=None, radius=None, bond_graph=None): self.spacing = spacing self.radius = radius @@ -471,12 +456,6 @@ def generate(self): [(np.cos(a) * self.radius, np.sin(a) * self.radius, 0) for a in angles] ) - def _next_coordinate(self): - pass - - def _check_path(self): - pass - class Knot(Path): def __init__(self, spacing, N, m, bond_graph=None): From 76fc28e12eece8dd9c0911ae63926d076d934c1f Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 4 Aug 2025 14:19:57 +0100 Subject: [PATCH 076/123] update doc strings in path --- mbuild/path.py | 52 +++----------------------------------------------- 1 file changed, 3 insertions(+), 49 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index fc802ab5f..7a6a91edd 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -10,53 +10,7 @@ from mbuild import Compound from mbuild.utils.geometry import bounding_box -""" -A path is basically a bond graph with coordinates/positions -assigned to the nodes. This is kind of what Compound is already. - -The interesting and challenging part is building up/creating the path. -This follows an algorithm to generate next coordinates. -Any random path generation algorithm will include -a rejection/acception step. We basically end up with -Monte Carlo. Some path algorithms won't be random (lamellae) - -Is Path essentially going to be a simple Monte carlo-ish -class that others can inherit from then implement their own approach? - -Classes that inherit from path will have their own -verions of next_coordinate(), check_path(), etc.. -We can define abstract methods for these in Path. -We can put universally useful methods in Path as well (e.g., n_list(), to_compound(), etc..). - -Some paths (lamellar, cyclic, spiral, etc..) would kind of just do -everything in generate() without having to use -next_coordinate() or check_path(). These would still need to be -defined, but just left empty and/or always return True in the case of check_path. -Maybe that means these kinds of "paths" need a different data structure? - -Do we have RandomPath and DeterministicPath? - -RandomPath ideas: -- Random walk (tons of possibilities here) -- Branching -- Multiple self-avoiding random walks -- ?? - -DeterministicPath ideas: -- Lamellar layers -- Cyclic polymers -- Helix -- Spiral -- Knots - -Some combination of these? -- Lamellar + random walk to generate semi-crystalline like structures? -- Make a new path by adding together multiple paths -- Some kind of data structure/functionality for new_path = Path(start_from_path=other_path) - -Other TODOs: - Make coordinates a property with a setter? Keep as a plain attribute? -""" +"""Classes to generate intra-molecular paths and configurations.""" try: from numba import njit @@ -80,7 +34,7 @@ def __init__(self, N=None, coordinates=None, bond_graph=None): self.N = len(coordinates) self.coordinates = coordinates # Neither is defined, use list for coordinates - # Use case: Lamellar - Won't know N initially + # Use case: Lamellar - Don't know N initially elif N is None and coordinates is None: self.N = N self.coordinates = [] @@ -320,7 +274,7 @@ def generate(self): if self.attempts == self.max_attempts and self.count < self.N: raise RuntimeError( "The maximum number attempts allowed have passed, and only ", - f"{self.count} sucsessful attempts were completed.", + f"{self.count - self._init_count} sucsessful attempts were completed.", "Try changing the parameters and running again.", ) From ffc7d4ff6c2496914c51ac75c0a82d3f4d9911a6 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 4 Aug 2025 14:20:27 +0100 Subject: [PATCH 077/123] Add abilityto set only one part of sim data dict --- mbuild/compound.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 959c390d1..64ef97839 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -2718,10 +2718,13 @@ def _clone_bonds(self, clone_of=None): "Particles outside of its containment hierarchy." ) - def _add_sim_data(self, state, forces, forcefield): - self._hoomd_data["state"] = state - self._hoomd_data["force_field"] = forcefield - self._hoomd_data["forces"] = forces + def _add_sim_data(self, state=None, forces=None, forcefield=None): + if state: + self._hoomd_data["state"] = state + if forces: + self._hoomd_data["forces"] = forces + if forcefield: + self._hoomd_data["forcefield"] = forcefield def _get_sim_data(self): if not self._hoomd_data: From 1ff20443a2ce4171edbe6bb63b32b1295385b186 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 21:53:10 +0000 Subject: [PATCH 078/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.5 → v0.12.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.5...v0.12.7) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc0f42566..088b92d99 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.5 + rev: v0.12.7 hooks: # Run the linter. - id: ruff From 35b912b23d67fc84468efe4a88a7b9a237a9c54d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 5 Aug 2025 12:43:21 +0100 Subject: [PATCH 079/123] rework parameters, save snapshot info after sim --- mbuild/simulation.py | 177 ++++++++++++++++++++++++++----------------- 1 file changed, 106 insertions(+), 71 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 34e612712..9dc384a28 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -40,29 +40,29 @@ def __init__( self.compound = compound self.forcefield = forcefield self.r_cut = r_cut - # Check if a sim method has been used on this compound already + # Check if a hoomd sim method has been used on this compound already if compound._hoomd_data: last_snapshot, last_forces, last_forcefield = compound._get_sim_data() # Check if the forcefield passed this time matches last time if forcefield == last_forcefield: - self.snapshot = last_snapshot + snapshot = last_snapshot self.forces = last_forces - self.forcefield = last_forcefield + forcefield = last_forcefield else: # New foyer/gmso forcefield has been passed, reapply - self.snapshot, self.forces = self._to_hoomd_snap_forces() + snapshot, self.forces = self._to_hoomd_snap_forces() compound._add_sim_data( - state=self.snapshot, forces=self.forces, forcefield=self.forcefield + state=snapshot, forces=self.forces, forcefield=forcefield ) else: # Sim method not used on this compound previously - self.snapshot, self.forces = self._to_hoomd_snap_forces() + snapshot, self.forces = self._to_hoomd_snap_forces() compound._add_sim_data( - state=self.snapshot, forces=self.forces, forcefield=self.forcefield + state=snapshot, forces=self.forces, forcefield=forcefield ) # Place holders for forces added/changed by specific methods below self.active_forces = [] self.inactive_forces = [] super(HoomdSimulation, self).__init__(device=device, seed=seed) - self.create_state_from_snapshot(self.snapshot) + self.create_state_from_snapshot(snapshot) def _to_hoomd_snap_forces(self): # Convret to GMSO, apply forcefield @@ -96,12 +96,12 @@ def set_fire_integrator( angmom_tol, energy_tol, methods, - finc_dt=1.1, - fdec_dt=0.4, - alpha_start=0.2, - fdec_alpha=0.95, - min_steps_adapt=5, - min_steps_conv=20, + finc_dt, + fdec_dt, + alpha_start, + fdec_alpha, + min_steps_adapt, + min_steps_conv, ): fire = hoomd.md.minimize.FIRE( dt=dt, @@ -125,6 +125,14 @@ def set_integrator(self, dt, method): integrator.methods = [method] self.operations.integrator = integrator + def _update_integrator_forces(self): + self.operations.integrator.forces = self.active_forces + + def _update_snapshot(self): + snapshot = self.state.get_snapshot() + # snapshot_copy = hoomd.Snapshot(snapshot) # full copy + self.compound._add_sim_data(state=snapshot) + def add_gsd_writer(self, file_name, write_period): """""" pass @@ -134,22 +142,22 @@ def update_positions(self): with self.state.cpu_local_snapshot as snap: particles = snap.particles.rtag[:] pos = snap.particles.position[particles] - self.compound.xyz = pos + self.compound.xyz = np.copy(pos) ## HOOMD METHODS ## -def remove_overlaps_displacement_capped( +def hoomd_cap_displacement( compound, forcefield, n_steps, - n_relax_steps, dt, r_cut, max_displacement, dpd_A, + n_relax_steps, + bond_k_scale=1, + angle_k_scale=1, run_on_gpu=False, - bond_scale=1, - angle_scale=1, seed=42, ): compound._kick() @@ -163,14 +171,20 @@ def remove_overlaps_displacement_capped( bond = sim.get_force(hoomd.md.bond.Harmonic) angle = sim.get_force(hoomd.md.angle.Harmonic) lj = sim.get_force(hoomd.md.pair.LJ) - dpd = sim.get_dpd_from_lj(A=dpd_A) # Scale bond K and angle K for param in bond.params: - bond.params[param]["k"] /= bond_scale - for param in angle.params: - angle.params[param]["k"] /= angle_scale + bond.params[param]["k"] *= bond_k_scale + sim.active_forces.append(bond) + if angle_k_scale != 0: + for param in angle.params: + angle.params[param]["k"] *= angle_k_scale + sim.active_forces.append(angle) + if dpd_A: + dpd = sim.get_dpd_from_lj(A=dpd_A) + sim.active_forces.append(dpd) + else: + sim.active_forces.append(lj) # Set up and run - sim.active_forces.extend([bond, angle, dpd]) displacement_capped = hoomd.md.methods.DisplacementCapped( filter=hoomd.filter.All(), maximum_displacement=max_displacement, @@ -179,39 +193,51 @@ def remove_overlaps_displacement_capped( sim.run(n_steps) # Run with LJ pairs, original bonds and angles if n_relax_steps > 0: - sim.operations.integrator.forces.remove(dpd) - sim.operations.integrator.forces.append(lj) + if dpd_A: # Repalce DPD with original LJ for final relaxation + sim.active_forces.remove(dpd) + sim.active_forces.append(lj) for param in bond.params: - bond.params[param]["k"] *= bond_scale - for param in angle.params: - angle.params[param]["k"] *= angle_scale + bond.params[param]["k"] /= bond_k_scale + if angle_k_scale != 0: + for param in angle.params: + angle.params[param]["k"] /= angle_k_scale + else: + sim.active_forces.append(angle) + sim._update_integrator_forces() sim.run(n_relax_steps) - with sim.state.cpu_local_snapshot as snap: - particles = snap.particles.rtag[:] - pos = snap.particles.position[particles] - compound.xyz = pos + sim.update_positions() + sim._update_snapshot() -def remove_overlaps_fire( + +def hoomd_fire( compound, forcefield, - fire_iteration_steps, - num_fire_iterations, run_on_gpu, + fire_iteration_steps, + num_fire_iterations=1, + n_relax_steps=1000, + dt=1e-5, + dpd_A=10, + integrate_dof=False, + min_steps_adapt=5, + min_steps_conv=100, + finc_dt=1.1, + fdec_dt=0.5, + alpha_start=0.1, + fdec_alpha=0.95, + force_tol=1e-2, + angmom_tol=1e-2, + energy_tol=1e-6, seed=42, r_cut=1.0, - A_initial=10, bond_k_scale=100, angle_k_scale=100, - dt=1e-5, - force_tol=1e-2, - angmom_tol=1e-2, - energy_tol=1e-3, - final_relaxation_steps=5000, gsd_file=None, ): - """Run a short HOOMD-Blue simulation with the FIRE integrator - to remove overlapping particles. + """Run a short HOOMD-Blue simulation with the FIRE integrator. + This method can be helpful for relaxing poor molecular + geometries or for removing overlapping particles. Parameters: ----------- @@ -238,14 +264,21 @@ def remove_overlaps_fire( bond = sim.get_force(hoomd.md.bond.Harmonic) angle = sim.get_force(hoomd.md.angle.Harmonic) lj = sim.get_force(hoomd.md.pair.LJ) - dpd = sim.get_dpd_from_lj(A=A_initial) # Scale bond K and angle K for param in bond.params: - bond.params[param]["k"] /= bond_k_scale - for param in angle.params: - angle.params[param]["k"] /= angle_k_scale + bond.params[param]["k"] *= bond_k_scale + sim.active_forces.append(bond) + # If angle_k_scale is zero, skip and don't append angle force + if angle_k_scale != 0: + for param in angle.params: + angle.params[param]["k"] *= angle_k_scale + sim.active_forces.append(angle) + if dpd_A: + dpd = sim.get_dpd_from_lj(A=dpd_A) + sim.active_forces.append(dpd) + else: + sim.active_forces.append(lj) # Set up and run - sim.active_forces.extend([bond, angle, dpd]) displacement_capped = hoomd.md.methods.DisplacementCapped( filter=hoomd.filter.All(), maximum_displacement=1e-3, @@ -255,34 +288,36 @@ def remove_overlaps_fire( force_tol=force_tol, angmom_tol=angmom_tol, energy_tol=energy_tol, - finc_dt=1.1, - fdec_dt=0.4, - alpha_start=0.2, - fdec_alpha=0.95, - min_steps_adapt=5, - min_steps_conv=20, + finc_dt=finc_dt, + fdec_dt=fdec_dt, + alpha_start=alpha_start, + fdec_alpha=fdec_alpha, + min_steps_adapt=min_steps_adapt, + min_steps_conv=min_steps_conv, methods=[displacement_capped], ) # Run FIRE sims with DPD + scaled bonds and angles for sim_num in range(num_fire_iterations): sim.run(fire_iteration_steps) - # while not (sim.operations.integrator.converged): - # sim.run(200) - # Re-scale bonds and angle force constants, Run DPD sim again - for param in bond.params: - bond.params[param]["k"] *= bond_k_scale - for param in angle.params: - angle.params[param]["k"] *= angle_k_scale - sim.run(fire_iteration_steps) - # Replace DPD with initial LJ force, quick relax - sim.operations.integrator.forces.remove(dpd) - sim.operations.integrator.forces.append(lj) - sim.run(final_relaxation_steps) + sim.operations.integrator.reset() + + # Re-scale bonds and angle force constants + if n_relax_steps > 0: + if dpd_A: # Replace DPD pair force with original LJ + sim.active_forces.remove(dpd) + sim.active_forces.append(lj) + for param in bond.params: + bond.params[param]["k"] /= bond_k_scale + if angle_k_scale != 0: + for param in angle.params: + angle.params[param]["k"] /= angle_k_scale + else: # Include angles in final relaxation step + sim.active_forces.append(angle) + sim._update_integrator_forces() + sim.run(n_relax_steps) # Update particle positions - with sim.state.cpu_local_snapshot as snap: - particles = snap.particles.rtag[:] - pos = snap.particles.position[particles] - compound.xyz = pos + sim._update_snapshot() + sim.update_positions() # Openbabel and OpenMM From 39455120c4c738e544289dc5217c8b6e096112a3 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 5 Aug 2025 13:45:25 +0100 Subject: [PATCH 080/123] Add method to Compound that returns indices of a child compound's particles --- mbuild/compound.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/mbuild/compound.py b/mbuild/compound.py index 64ef97839..3fa0199a4 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -242,6 +242,22 @@ def successors(self): for subpart in part.successors(): yield subpart + def get_child_indices(self, child): + """Gets the indices of particles belonging to a child compound. + + Parameters: + ----------- + child : mb.Compound + Compound that belongs to self.children + """ + if child not in self.children: + raise ValueError(f"{child} is not a child in this compound's hiearchy.") + matches = np.any( + np.all(self.xyz[:, np.newaxis, :] == child.xyz, axis=2), axis=1 + ) + matching_indices = np.where(matches)[0] + return matching_indices + def set_bond_graph(self, new_graph): """Manually set the compound's complete bond graph. From c4885fef7e31d029daf68e96b02dc21fe65f60c8 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 5 Aug 2025 14:51:59 +0100 Subject: [PATCH 081/123] Add option to set fixed compounds or integrate compounds --- mbuild/simulation.py | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 9dc384a28..a01c3005c 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -24,6 +24,9 @@ def __init__( r_cut, run_on_gpu, seed, + integrate_compounds=None, + fixed_compounds=None, + gsd_file_name=None, ): if run_on_gpu: try: @@ -40,6 +43,8 @@ def __init__( self.compound = compound self.forcefield = forcefield self.r_cut = r_cut + self.integrate_compounds = integrate_compounds + self.fixed_compounds = fixed_compounds # Check if a hoomd sim method has been used on this compound already if compound._hoomd_data: last_snapshot, last_forces, last_forcefield = compound._get_sim_data() @@ -75,6 +80,30 @@ def _to_hoomd_snap_forces(self): forces = list(set().union(*forces.values())) return snap, forces + def get_integrate_group(self): + # Get indices of compounds to include in integration + if self.integrate_compounds and not self.fixed_compounds: + integrate_indices = [] + for comp in self.integrate_compounds: + integrate_indices.extend(list(self.compound.get_child_indices(comp))) + return hoomd.filter.Tags(integrate_indices) + # Get indices of compounds to NOT include in integration + elif self.fixed_compounds and not self.integrate_compounds: + fix_indices = [] + for comp in self.fixed_compounds: + fix_indices.extend(list(self.compound.get_child_indices(comp))) + return hoomd.filter.SetDifference( + hoomd.filter.All(), hoomd.filter.Tags(fix_indices) + ) + # Both are passed in, not supported + elif self.integrate_compounds and self.fixed_compounds: + raise RuntimeError( + "You can specify only one of integrate_compounds and fixed_compounds." + ) + # Neither are given, include everything in integration + else: + return hoomd.filter.All() + def get_force(self, instance): for force in set(self.forces + self.active_forces + self.inactive_forces): if isinstance(force, instance): @@ -155,6 +184,8 @@ def hoomd_cap_displacement( max_displacement, dpd_A, n_relax_steps, + fixed_compounds=None, + integrate_compounds=None, bond_k_scale=1, angle_k_scale=1, run_on_gpu=False, @@ -167,6 +198,8 @@ def hoomd_cap_displacement( r_cut=r_cut, run_on_gpu=run_on_gpu, seed=seed, + integrate_compounds=integrate_compounds, + fixed_compounds=fixed_compounds, ) bond = sim.get_force(hoomd.md.bond.Harmonic) angle = sim.get_force(hoomd.md.angle.Harmonic) @@ -186,7 +219,7 @@ def hoomd_cap_displacement( sim.active_forces.append(lj) # Set up and run displacement_capped = hoomd.md.methods.DisplacementCapped( - filter=hoomd.filter.All(), + filter=sim.get_integrate_group(), maximum_displacement=max_displacement, ) sim.set_integrator(method=displacement_capped, dt=dt) @@ -215,6 +248,8 @@ def hoomd_fire( forcefield, run_on_gpu, fire_iteration_steps, + fixed_compounds=None, + integrate_compounds=None, num_fire_iterations=1, n_relax_steps=1000, dt=1e-5, @@ -260,6 +295,8 @@ def hoomd_fire( r_cut=r_cut, run_on_gpu=run_on_gpu, seed=seed, + integrate_compounds=integrate_compounds, + fixed_compounds=fixed_compounds, ) bond = sim.get_force(hoomd.md.bond.Harmonic) angle = sim.get_force(hoomd.md.angle.Harmonic) @@ -280,7 +317,7 @@ def hoomd_fire( sim.active_forces.append(lj) # Set up and run displacement_capped = hoomd.md.methods.DisplacementCapped( - filter=hoomd.filter.All(), + filter=sim.get_integrate_group(), maximum_displacement=1e-3, ) sim.set_fire_integrator( From 5a11b12d5030b6331de765647b62549483630ab1 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 5 Aug 2025 15:26:03 +0100 Subject: [PATCH 082/123] use nvt in FIRE --- mbuild/simulation.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index a01c3005c..3ef7c138a 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -248,10 +248,10 @@ def hoomd_fire( forcefield, run_on_gpu, fire_iteration_steps, - fixed_compounds=None, - integrate_compounds=None, num_fire_iterations=1, n_relax_steps=1000, + fixed_compounds=None, + integrate_compounds=None, dt=1e-5, dpd_A=10, integrate_dof=False, @@ -316,10 +316,7 @@ def hoomd_fire( else: sim.active_forces.append(lj) # Set up and run - displacement_capped = hoomd.md.methods.DisplacementCapped( - filter=sim.get_integrate_group(), - maximum_displacement=1e-3, - ) + nvt = hoomd.md.methods.ConstantVolume(filter=sim.get_integrate_group()) sim.set_fire_integrator( dt=dt, force_tol=force_tol, @@ -331,7 +328,7 @@ def hoomd_fire( fdec_alpha=fdec_alpha, min_steps_adapt=min_steps_adapt, min_steps_conv=min_steps_conv, - methods=[displacement_capped], + methods=[nvt], ) # Run FIRE sims with DPD + scaled bonds and angles for sim_num in range(num_fire_iterations): From 15ebe8117e0f7dde02c4cf0843d4da64251bb477 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 6 Aug 2025 13:55:35 +0100 Subject: [PATCH 083/123] fix introduced bug when adding compounds from a list --- mbuild/compound.py | 2 +- mbuild/path.py | 90 +++++++++++++++++++++++++++++++++++----------- 2 files changed, 71 insertions(+), 21 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 3fa0199a4..33ad71c8d 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -705,7 +705,7 @@ def add( if self.root.bond_graph.has_node(self): self.root.bond_graph.remove_node(self) # compose the bond graph of all the children with the root - self.root._bond_graph = nx.compose( + self.root.bond_graph = nx.compose( self.root.bond_graph, children_bond_graph ) for i, child in enumerate(compound_list): diff --git a/mbuild/path.py b/mbuild/path.py index 7a6a91edd..5d0b32f72 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -56,14 +56,9 @@ def _extend_coordinates(self, N): @abstractmethod def generate(self): - """Abstract class for running a Path generation algorithm - - This method should: - ----------------- - - Set initial conditions - - Implement Path generation steps by calling _next_coordinate() and _check_path() - - Handle cases of next coordiante acceptance - - Handle cases of next coordinate rejection + """Abstract class for running a Path generation algorithm. + Sub-classes that inherit from Path should implement their + own method under genrate. """ pass @@ -140,15 +135,24 @@ def __init__( radius : float, required Radius of sites used in checking for overlaps. min_angle : float, required - Minimum angle used when randomly selecting angle + Minimum angle (radians) used when randomly selecting angle for the next step. max_angle : float, required - Maximum angle used when randomly selecting angle + Maximum angle (radians) used when randomly selecting angle for the next step. seed : int, default = 42 Random seed + trial_batch_size : int, default = 5 + The number of trial moves to attempt in parallel for each step. + Using larger values can improve success rates for more dense + random walks. + start_from_path : mbuild.path.Path, optional + An instance of a previous Path to start the random walk from. + start_from_path_index : int, optional + The index of `start_from_path` to use as the initial point + for the random walk. tolerance : float, default = 1e-4 - Tolerance used for rounding. + Tolerance used for rounding and checkig for overlaps. bond_graph : networkx.graph.Graph; optional Sets the bonding of sites along the path. """ @@ -280,6 +284,24 @@ def generate(self): class Lamellar(Path): + """Generate a 2-D or 3_D lamellar-like path. + + Parameters + ---------- + num_layers : int, required + The number of times the lamellar path curves around + creating another layer. + layer_separation : float (nm), required + The distance between any two layers. + spacing : float (nm), required + The distance between two sites along the path. + num_stacks : int, required + The number of times to repeat each layer in the Z direction. + Setting this to 1 creates a single, 2D lamellar-like path. + stack_separation : float (nm), required + The distance between two stacked layers. + """ + def __init__( self, num_layers, @@ -373,7 +395,17 @@ def generate(self): class StraightLine(Path): - """Generates a set of coordinates in a straight line along a given axis.""" + """Generates a set of coordinates in a straight line along a given axis. + + Parameters + ---------- + spacing : float, required + The distance between sites along the path. + N : int, required + The number of sites in the path. + direction : array-like (1,3), default = (1,0,0) + The direction to align the path along. + """ def __init__(self, spacing, N, direction=(1, 0, 0), bond_graph=None): self.spacing = spacing @@ -387,7 +419,25 @@ def generate(self): class Cyclic(Path): - """Generates a set of coordinates evenly spaced along a circle.""" + """Generates a set of coordinates evenly spaced along a circle. + + Parameters + ---------- + spacing : float, optional + Distance between sites along the path. + N : int, optional + Number of sites in the cyclic path. + radius : float, optional + The radius (nm) of the cyclic path. + + Notes + ----- + Only two of spacing, N and radius can be defined, as the third + is determined by the other two. + + If using this Path to build a cyclic polymer, be sure to + set `bond_head_tail = True` in `mbuild.polymer.Polymer.build_from_path` + """ def __init__(self, spacing=None, N=None, radius=None, bond_graph=None): self.spacing = spacing @@ -412,6 +462,8 @@ def generate(self): class Knot(Path): + """Generate a knot path.""" + def __init__(self, spacing, N, m, bond_graph=None): self.spacing = spacing self.m = m @@ -459,8 +511,7 @@ class Helix(Path): def __init__( self, N, radius, rise, twist, right_handed=True, bottom_up=True, bond_graph=None ): - """ - Generate helical path. + """Generate helical path. Parameters: ----------- @@ -472,7 +523,7 @@ def __init__( Twist per site in path (degrees) right_handed : bool, default True Set the handedness of the helical twist - Set to false for a left handed twist + Set to False for a left handed twist bottom_up : bool, default True If True, the twist is in the positive Z direction If False, the twist is in the negative Z direction @@ -503,8 +554,7 @@ def generate(self): class Spiral2D(Path): def __init__(self, N, a, b, spacing, bond_graph=None): - """ - Generate a 2D spiral path in the XY plane. + """Generate a 2D spiral path in the XY plane. Parameters ---------- @@ -676,14 +726,14 @@ def _random_coordinate_numba( for i in range(batch_size): for j in range(3): r_perp_norm[i, j] = r_perp[i, j] / norms[i] - + # Batch of trial vectors v2s = np.empty((batch_size, 3), dtype=np.float32) for i in range(batch_size): cos_theta = np.cos(thetas[i]) sin_theta = np.sin(thetas[i]) for j in range(3): v2s[i, j] = cos_theta * v1_norm[j] + sin_theta * r_perp_norm[i, j] - + # Batch of trial positions next_positions = np.empty((batch_size, 3), dtype=np.float32) for i in range(batch_size): for j in range(3): From 23422ef84ce686b5e4a6ca0de7d717e924997556 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 6 Aug 2025 15:31:04 +0100 Subject: [PATCH 084/123] Add some code comments --- mbuild/path.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/mbuild/path.py b/mbuild/path.py index 5d0b32f72..653119d13 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -470,6 +470,8 @@ def __init__(self, spacing, N, m, bond_graph=None): super(Knot, self).__init__(N=N, bond_graph=bond_graph) def generate(self): + # Generate dense sites first, sample actual ones later from spacing + # Prevents spacing between sites changing with curvature t_dense = np.linspace(0, 2 * np.pi, 5000) # Base (unscaled) curve if self.m == 3: # Trefoil knot (3_1) From 64d546b2ceba69c2217ac642cc4773838b4bc377 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 7 Aug 2025 10:10:30 +0100 Subject: [PATCH 085/123] Add numba to env files --- environment-dev.yml | 1 + environment.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/environment-dev.yml b/environment-dev.yml index 6c049177b..c225668c0 100644 --- a/environment-dev.yml +++ b/environment-dev.yml @@ -4,6 +4,7 @@ channels: dependencies: - python>=3.10,<=3.13 - boltons + - numba - numpy>=2.0,<2.3 - sympy - unyt>=2.9.5 diff --git a/environment.yml b/environment.yml index b6293aec9..9fd312e70 100644 --- a/environment.yml +++ b/environment.yml @@ -7,6 +7,7 @@ dependencies: - packmol>=20.15 - gmso>=0.12.0 - garnett + - numba - parmed>=3.4.3 - pycifrw - python>=3.10,<=3.13 From c07442adbaf6c8259359b9e2f25ae16eee08ebc4 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 7 Aug 2025 13:18:16 +0100 Subject: [PATCH 086/123] clear _hoomd_data when cloning a compound --- mbuild/compound.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mbuild/compound.py b/mbuild/compound.py index 33ad71c8d..7abeaeb1d 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -2675,6 +2675,7 @@ def _clone(self, clone_of=None, root_container=None): newone._periodicity = deepcopy(self._periodicity) newone._charge = deepcopy(self._charge) newone._mass = deepcopy(self._mass) + newone._hoomd_data = {} if hasattr(self, "index"): newone.index = deepcopy(self.index) From fd995be799d187ae0f79d27d8e9c62b382d40e7d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 8 Aug 2025 15:45:12 +0100 Subject: [PATCH 087/123] update version for codeql runners --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 42af8c633..3dfe6e23e 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,15 +27,15 @@ jobs: uses: actions/checkout@v3 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{ matrix.language }}" From 26c153a5c0bec1762d4292646572389d04b7df8d Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 8 Aug 2025 18:49:42 +0100 Subject: [PATCH 088/123] Change RNG for use with numba, use numba methods only in random walk, add basic tests --- mbuild/path.py | 90 +++++++++++++++++---------------------- mbuild/tests/test_path.py | 39 +++++++++++++++++ 2 files changed, 77 insertions(+), 52 deletions(-) create mode 100644 mbuild/tests/test_path.py diff --git a/mbuild/path.py b/mbuild/path.py index 653119d13..47cc093af 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -1,24 +1,16 @@ -"""Molecular paths and templates""" +"""Classes to generate intra-molecular paths and configurations.""" import math from abc import abstractmethod import freud import numpy as np +from numba import njit from scipy.interpolate import interp1d from mbuild import Compound from mbuild.utils.geometry import bounding_box -"""Classes to generate intra-molecular paths and configurations.""" - -try: - from numba import njit - - _NUMBA_AVAILABLE = True -except ImportError: - _NUMBA_AVAILABLE = False - class Path: def __init__(self, N=None, coordinates=None, bond_graph=None): @@ -112,13 +104,12 @@ def __init__( radius, min_angle, max_angle, - max_attempts, - seed, - trial_batch_size=5, + max_attempts=1e5, + seed=42, + trial_batch_size=20, start_from_path=None, start_from_path_index=None, tolerance=1e-5, - use_numba=False, bond_graph=None, ): """Generates coordinates from a self avoiding random walk using @@ -185,27 +176,20 @@ def __init__( # Need this for error message about reaching max tries self._init_count = self.count # Get methods to use for random walk - if not use_numba: - self.next_coordinate = _random_coordinate_numpy - self.check_path = _check_path_numpy - elif use_numba and _NUMBA_AVAILABLE: - self.next_coordinate = _random_coordinate_numba - self.check_path = _check_path_numba - elif use_numba and not _NUMBA_AVAILABLE: - raise RuntimeError( - "numba was not found. Set `use_numba` to `False` or add numba to your environment." - ) + self.next_coordinate = _random_coordinate_numba + self.check_path = _check_path_numba + # Create RNG state. + self.rng = np.random.default_rng(seed) super(HardSphereRandomWalk, self).__init__( coordinates=coordinates, N=None, bond_graph=bond_graph ) def generate(self): - np.random.seed(self.seed) if not self.start_from_path: # With fixed bond lengths, first move is always accepted - phi = np.random.uniform(0, 2 * np.pi) - theta = np.random.uniform(0, np.pi) + phi = self.rng.uniform(0, 2 * np.pi) + theta = self.rng.uniform(0, np.pi) next_pos = np.array( [ self.bond_length * np.sin(theta) * np.cos(phi), @@ -215,19 +199,19 @@ def generate(self): ) self.coordinates[1] = next_pos self.count += 1 # We already have 1 accepted move - self.cound_adj = 0 else: # Start random walk from a previous set of coordinates # Start a while loop here started_next_path = False while not started_next_path: + batch_angles, batch_vectors = self._generate_random_trials() new_xyzs = self.next_coordinate( pos1=self.start_from_path.coordinates[self.start_from_path_index], pos2=self.start_from_path.coordinates[ self.start_from_path_index - 1 ], bond_length=self.bond_length, - min_angle=self.min_angle, - max_angle=self.max_angle, + thetas=batch_angles, + r_vectors=batch_vectors, batch_size=self.trial_batch_size, ) new_xyz_found = False @@ -255,12 +239,13 @@ def generate(self): ) while self.count < self.N - 1: + batch_angles, batch_vectors = self._generate_random_trials() new_xyzs = self.next_coordinate( pos1=self.coordinates[self.count], pos2=self.coordinates[self.count - 1], bond_length=self.bond_length, - min_angle=self.min_angle, - max_angle=self.max_angle, + thetas=batch_angles, + r_vectors=batch_vectors, batch_size=self.trial_batch_size, ) for xyz in new_xyzs: @@ -282,6 +267,18 @@ def generate(self): "Try changing the parameters and running again.", ) + def _generate_random_trials(self): + """Generate a batch of random angles and vectors using the RNG state.""" + # Batch of angles used to determine next positions + thetas = self.rng.uniform( + self.min_angle, self.max_angle, size=self.trial_batch_size + ).astype(np.float32) + # Batch of random vectors and center around origin (0,0,0) + r = self.rng.uniform(-0.5, 0.5, size=(self.trial_batch_size, 3)).astype( + np.float32 + ) + return thetas, r + class Lamellar(Path): """Generate a 2-D or 3_D lamellar-like path. @@ -328,7 +325,6 @@ def generate(self): arc_num_points = math.floor(arc_length / self.bond_length) arc_angle = np.pi / (arc_num_points + 1) # incremental angle arc_angles = np.linspace(arc_angle, np.pi, arc_num_points, endpoint=False) - # stack_coordinates = [] for i in range(self.num_layers): if i % 2 == 0: # Even layer; build from left to right layer = [ @@ -489,7 +485,7 @@ def generate(self): y = (R + r * np.cos(5 * t_dense)) * np.sin(2 * t_dense) z = r * np.sin(5 * t_dense) else: - raise ValueError("Only m=3, m=4 and m=5 are supported.") + raise ValueError("Only m=3, m=4 and m=5 are currently supported.") # Compute arc length of a base curve coords_dense = np.stack((x, y, z), axis=1) deltas = np.diff(coords_dense, axis=0) @@ -675,7 +671,7 @@ def _check_path_numpy(existing_points, new_point, radius, tolerance): return not np.any(sq_dists < min_sq_dist) -@njit(fastmath=True) +@njit(cache=True, fastmath=True) def norm(vec): s = 0.0 for i in range(vec.shape[0]): @@ -683,38 +679,28 @@ def norm(vec): return np.sqrt(s) -@njit(fastmath=True) +@njit(cache=True, fastmath=True) def _random_coordinate_numba( pos1, pos2, bond_length, - min_angle, - max_angle, + thetas, + r_vectors, batch_size, ): v1 = pos2 - pos1 v1_norm = v1 / norm(v1) - - thetas = np.empty(batch_size, dtype=np.float32) - for i in range(batch_size): - thetas[i] = min_angle + (max_angle - min_angle) * np.random.random() - - r = np.empty((batch_size, 3), dtype=np.float32) - for i in range(batch_size): - for j in range(3): - r[i, j] = np.random.random() - 0.5 - dot_products = np.empty(batch_size, dtype=np.float32) for i in range(batch_size): dot = 0.0 for j in range(3): - dot += r[i, j] * v1_norm[j] + dot += r_vectors[i, j] * v1_norm[j] dot_products[i] = dot r_perp = np.empty((batch_size, 3), dtype=np.float32) for i in range(batch_size): for j in range(3): - r_perp[i, j] = r[i, j] - dot_products[i] * v1_norm[j] + r_perp[i, j] = r_vectors[i, j] - dot_products[i] * v1_norm[j] norms = np.empty(batch_size, dtype=np.float32) for i in range(batch_size): @@ -728,7 +714,7 @@ def _random_coordinate_numba( for i in range(batch_size): for j in range(3): r_perp_norm[i, j] = r_perp[i, j] / norms[i] - # Batch of trial vectors + # Batch of trial vectors using angles and r_norms v2s = np.empty((batch_size, 3), dtype=np.float32) for i in range(batch_size): cos_theta = np.cos(thetas[i]) @@ -743,7 +729,7 @@ def _random_coordinate_numba( return next_positions -@njit +@njit(cache=True, fastmath=True) def _check_path_numba(existing_points, new_point, radius, tolerance): min_sq_dist = (radius - tolerance) ** 2 for i in range(existing_points.shape[0]): diff --git a/mbuild/tests/test_path.py b/mbuild/tests/test_path.py new file mode 100644 index 000000000..d434c185f --- /dev/null +++ b/mbuild/tests/test_path.py @@ -0,0 +1,39 @@ +import numpy as np + +from mbuild.path import HardSphereRandomWalk +from mbuild.tests.base_test import BaseTest + + +class TestRandomWalk(BaseTest): + def test_random_walk(self): + rw_path = HardSphereRandomWalk( + N=20, + bond_length=0.25, + radius=0.22, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + seed=14, + ) + assert len(rw_path.coordinates) == 20 + + def test_seeds(self): + rw_path_1 = HardSphereRandomWalk( + N=20, + bond_length=0.25, + radius=0.22, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + seed=14, + ) + rw_path_2 = HardSphereRandomWalk( + N=20, + bond_length=0.25, + radius=0.22, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + seed=14, + ) + assert np.allclose(rw_path_1.coordinates, rw_path_2.coordinates, atol=1e-4) From 3bcd660fd348116be643e7e73b29b6cf7de75a9e Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 8 Aug 2025 20:53:23 +0100 Subject: [PATCH 089/123] Add a couple more tests, code comments and doc strings --- .gitignore | 1 + mbuild/path.py | 39 ++++----------------------------------- mbuild/tests/test_path.py | 24 ++++++++++++++++++++++++ 3 files changed, 29 insertions(+), 35 deletions(-) diff --git a/.gitignore b/.gitignore index c312a17b1..0f4c9cdfd 100755 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ test-output.xml *.pymon *.ipynb_checkpoints *DS_Store* +__pycache__ # C extensions *.so diff --git a/mbuild/path.py b/mbuild/path.py index 47cc093af..49938fdcf 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -159,10 +159,9 @@ def __init__( self.start_from_path_index = start_from_path_index self.start_from_path = start_from_path if start_from_path and start_from_path_index is not None: - # TODO: Do we need np.copy here? coordinates = np.concatenate( ( - np.copy(start_from_path.coordinates).astype(np.float32), + start_from_path.coordinates.astype(np.float32), np.zeros((N, 3), dtype=np.float32), ), axis=0, @@ -200,7 +199,6 @@ def generate(self): self.coordinates[1] = next_pos self.count += 1 # We already have 1 accepted move else: # Start random walk from a previous set of coordinates - # Start a while loop here started_next_path = False while not started_next_path: batch_angles, batch_vectors = self._generate_random_trials() @@ -639,40 +637,9 @@ def generate(self): # Internal helper/utility methods below: - - -def _random_coordinate_numpy(pos1, pos2, bond_length, min_angle, max_angle, batch_size): - # Vector formed by previous 2 coordinates - v1 = pos2 - pos1 - v1_norm = v1 / np.linalg.norm(v1) - # Generate batch of random angles - thetas = np.random.uniform(min_angle, max_angle, size=batch_size) - # Batch of random vectors and center around origin (0,0,0) - r = np.random.rand(batch_size, 3) - 0.5 - dot_products = np.dot(r, v1_norm) - r_perp = r - dot_products[:, np.newaxis] * v1_norm - norms = np.linalg.norm(r_perp, axis=1) - for norm in norms: - if norm < 1e-6: - norm = 1.0 - r_perp_norm = r_perp / norms[:, np.newaxis] - v2s = ( - np.cos(thetas)[:, np.newaxis] * v1_norm - + np.sin(thetas)[:, np.newaxis] * r_perp_norm - ) - next_positions = pos1 + v2s * bond_length - return next_positions - - -def _check_path_numpy(existing_points, new_point, radius, tolerance): - """Check new trial point against previous ones only.""" - sq_dists = np.sum((existing_points - new_point) ** 2, axis=1) - min_sq_dist = (radius - tolerance) ** 2 - return not np.any(sq_dists < min_sq_dist) - - @njit(cache=True, fastmath=True) def norm(vec): + """Used by HardSphereRandomWalk.""" s = 0.0 for i in range(vec.shape[0]): s += vec[i] * vec[i] @@ -688,6 +655,7 @@ def _random_coordinate_numba( r_vectors, batch_size, ): + """Default method for HardSphereRandomWalk""" v1 = pos2 - pos1 v1_norm = v1 / norm(v1) dot_products = np.empty(batch_size, dtype=np.float32) @@ -731,6 +699,7 @@ def _random_coordinate_numba( @njit(cache=True, fastmath=True) def _check_path_numba(existing_points, new_point, radius, tolerance): + """Default method for HardSphereRandomWalk.""" min_sq_dist = (radius - tolerance) ** 2 for i in range(existing_points.shape[0]): dist_sq = 0.0 diff --git a/mbuild/tests/test_path.py b/mbuild/tests/test_path.py index d434c185f..13484d956 100644 --- a/mbuild/tests/test_path.py +++ b/mbuild/tests/test_path.py @@ -37,3 +37,27 @@ def test_seeds(self): seed=14, ) assert np.allclose(rw_path_1.coordinates, rw_path_2.coordinates, atol=1e-4) + + def test_from_path(self): + rw_path = HardSphereRandomWalk( + N=20, + bond_length=0.25, + radius=0.22, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + seed=14, + ) + + rw_path2 = HardSphereRandomWalk( + N=20, + bond_length=0.25, + radius=0.22, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + seed=14, + start_from_path=rw_path, + start_from_path_index=-1, + ) + assert len(rw_path2.coordinates) == 40 From 425062aaa77c565fbbffebaf8a2c03b1fd3e6e1f Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 8 Aug 2025 22:22:12 +0100 Subject: [PATCH 090/123] Try making numba methods more vectorized --- mbuild/path.py | 62 ++++++++++++++++---------------------------------- 1 file changed, 19 insertions(+), 43 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index 49938fdcf..f2131bb15 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -31,7 +31,7 @@ def __init__(self, N=None, coordinates=None, bond_graph=None): self.N = N self.coordinates = [] else: - raise ValueError("Specify either one of N and coordinates, or neither") + raise ValueError("Specify either one of N and coordinates, or neither.") self.generate() if self.N is None: self.N = len(self.coordinates) @@ -70,7 +70,7 @@ def neighbor_list(self, r_max, coordinates=None, box=None): return nlist def to_compound(self, bead_name="_A", bead_mass=1): - """Visualize a path as an mBuild Compound""" + """Visualize a path as an mBuild Compound.""" compound = Compound() for xyz in self.coordinates: compound.add(Compound(name=bead_name, mass=bead_mass, pos=xyz)) @@ -174,7 +174,7 @@ def __init__( self.start_index = 0 # Need this for error message about reaching max tries self._init_count = self.count - # Get methods to use for random walk + # Select methods to use for random walk self.next_coordinate = _random_coordinate_numba self.check_path = _check_path_numba # Create RNG state. @@ -279,7 +279,7 @@ def _generate_random_trials(self): class Lamellar(Path): - """Generate a 2-D or 3_D lamellar-like path. + """Generate a 2-D or 3-D lamellar-like path. Parameters ---------- @@ -351,7 +351,7 @@ def generate(self): self.coordinates.extend(layer) if self.num_stacks > 1: first_stack_coordinates = np.copy(np.array(self.coordinates)) - # Now find info for curves between stacked layers + # Get info for curves between stacked layers r = self.stack_separation / 2 arc_length = r * np.pi arc_num_points = math.floor(arc_length / self.bond_length) @@ -430,7 +430,7 @@ class Cyclic(Path): is determined by the other two. If using this Path to build a cyclic polymer, be sure to - set `bond_head_tail = True` in `mbuild.polymer.Polymer.build_from_path` + set ``bond_head_tail = True`` in ``mbuild.polymer.Polymer.build_from_path`` """ def __init__(self, spacing=None, N=None, radius=None, bond_graph=None): @@ -527,7 +527,7 @@ def __init__( Notes: ------ To create a double helix pair (e.g., DNA) create two paths - with opposite values for right_handed and bottom_up + with opposite values for right_handed and bottom_up. """ self.radius = radius self.rise = rise @@ -655,45 +655,21 @@ def _random_coordinate_numba( r_vectors, batch_size, ): - """Default method for HardSphereRandomWalk""" + """Default method for HardSphereRandomWalk.""" v1 = pos2 - pos1 v1_norm = v1 / norm(v1) - dot_products = np.empty(batch_size, dtype=np.float32) - for i in range(batch_size): - dot = 0.0 - for j in range(3): - dot += r_vectors[i, j] * v1_norm[j] - dot_products[i] = dot - - r_perp = np.empty((batch_size, 3), dtype=np.float32) - for i in range(batch_size): - for j in range(3): - r_perp[i, j] = r_vectors[i, j] - dot_products[i] * v1_norm[j] - - norms = np.empty(batch_size, dtype=np.float32) - for i in range(batch_size): - norms[i] = norm(r_perp[i]) - - for i in range(batch_size): - if norms[i] < 1e-6: - norms[i] = 1.0 - - r_perp_norm = np.empty((batch_size, 3), dtype=np.float32) - for i in range(batch_size): - for j in range(3): - r_perp_norm[i, j] = r_perp[i, j] / norms[i] - # Batch of trial vectors using angles and r_norms - v2s = np.empty((batch_size, 3), dtype=np.float32) - for i in range(batch_size): - cos_theta = np.cos(thetas[i]) - sin_theta = np.sin(thetas[i]) - for j in range(3): - v2s[i, j] = cos_theta * v1_norm[j] + sin_theta * r_perp_norm[i, j] + dot_products = (r_vectors * v1_norm).sum(axis=1) + r_perp = r_vectors - dot_products[:, None] * v1_norm + norms = np.sqrt((r_perp * r_perp).sum(axis=1)) + # Handle rare cases where rprep vectors approach zero + norms = np.where(norms < 1e-6, 1.0, norms) + r_perp_norm = r_perp / norms[:, None] + # Batch of trial next-step vectors using angles and r_norms + cos_thetas = np.cos(thetas) + sin_thetas = np.sin(thetas) + v2s = cos_thetas[:, None] * v1_norm + sin_thetas[:, None] * r_perp_norm # Batch of trial positions - next_positions = np.empty((batch_size, 3), dtype=np.float32) - for i in range(batch_size): - for j in range(3): - next_positions[i, j] = pos1[j] + v2s[i, j] * bond_length + next_positions = pos1 + v2s * bond_length return next_positions From 0c333510bfa1fa60c63e72bb6727df337d141969 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sat, 9 Aug 2025 00:31:54 +0100 Subject: [PATCH 091/123] Add constraint classes, implement in random walk --- mbuild/path.py | 21 ++++++++++++-- mbuild/tests/test_path.py | 17 +++++++++++ mbuild/utils/volumes.py | 61 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 mbuild/utils/volumes.py diff --git a/mbuild/path.py b/mbuild/path.py index f2131bb15..a6f5f1b54 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -104,6 +104,7 @@ def __init__( radius, min_angle, max_angle, + volume_constraint=None, max_attempts=1e5, seed=42, trial_batch_size=20, @@ -152,12 +153,15 @@ def __init__( self.min_angle = min_angle self.max_angle = max_angle self.seed = seed + self.volume_constraint = volume_constraint self.tolerance = tolerance self.trial_batch_size = int(trial_batch_size) self.max_attempts = int(max_attempts) self.attempts = 0 self.start_from_path_index = start_from_path_index self.start_from_path = start_from_path + + # This random walk is including a previous path if start_from_path and start_from_path_index is not None: coordinates = np.concatenate( ( @@ -168,8 +172,11 @@ def __init__( ) self.count = len(start_from_path.coordinates) - 1 N = None - else: + else: # Not starting from another path coordinates = np.zeros((N, 3), dtype=np.float32) + # If volume constraint; can't always start with (0,0,0) + if self.volume_constraint: + coordinates[0] = self.volume_constraint.center self.count = 0 self.start_index = 0 # Need this for error message about reaching max tries @@ -196,7 +203,7 @@ def generate(self): self.bond_length * np.cos(theta), ] ) - self.coordinates[1] = next_pos + self.coordinates[1] = self.coordinates[0] + next_pos self.count += 1 # We already have 1 accepted move else: # Start random walk from a previous set of coordinates started_next_path = False @@ -212,6 +219,11 @@ def generate(self): r_vectors=batch_vectors, batch_size=self.trial_batch_size, ) + if self.volume_constraint: + is_inside_mask = self.volume_constraint.is_inside( + points=new_xyzs, radius=self.radius + ) + new_xyzs = new_xyzs[is_inside_mask] new_xyz_found = False for xyz in new_xyzs: if self.check_path( @@ -246,6 +258,11 @@ def generate(self): r_vectors=batch_vectors, batch_size=self.trial_batch_size, ) + if self.volume_constraint: + is_inside_mask = self.volume_constraint.is_inside( + points=new_xyzs, radius=self.radius + ) + new_xyzs = new_xyzs[is_inside_mask] for xyz in new_xyzs: if self.check_path( existing_points=self.coordinates[: self.count + 1], diff --git a/mbuild/tests/test_path.py b/mbuild/tests/test_path.py index 13484d956..3927a85e4 100644 --- a/mbuild/tests/test_path.py +++ b/mbuild/tests/test_path.py @@ -2,6 +2,8 @@ from mbuild.path import HardSphereRandomWalk from mbuild.tests.base_test import BaseTest +from mbuild.utils.geometry import bounding_box +from mbuild.utils.volumes import CuboidConstraint class TestRandomWalk(BaseTest): @@ -61,3 +63,18 @@ def test_from_path(self): start_from_path_index=-1, ) assert len(rw_path2.coordinates) == 40 + + def test_walk_inside_cube(self): + cube = CuboidConstraint(Lx=5, Ly=5, Lz=5) + rw_path = HardSphereRandomWalk( + N=50, + bond_length=0.25, + radius=0.22, + volume_constraint=cube, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + seed=14, + ) + bounds = bounding_box(rw_path.coordinates) + assert np.all(bounds < np.array([5, 5, 5])) diff --git a/mbuild/utils/volumes.py b/mbuild/utils/volumes.py new file mode 100644 index 000000000..4760f0202 --- /dev/null +++ b/mbuild/utils/volumes.py @@ -0,0 +1,61 @@ +import numpy as np +from numba import njit + + +class Constraint: + def __init__(self): + pass + + def is_inside(self, point): + """Return True if point satisfies constraint (inside), else False.""" + raise NotImplementedError("Must be implemented in subclasses") + + +class CuboidConstraint(Constraint): + def __init__(self, Lx, Ly, Lz, center=(0, 0, 0)): + self.center = np.asarray(center) + self.mins = self.center - np.array([Lx, Ly, Lz]) + self.maxs = self.center + np.array([Lx, Ly, Lz]) + + # Point to actual method to use + def is_inside(self, points, radius): + return is_inside_cuboid( + mins=self.mins, maxs=self.maxs, points=points, radius=radius + ) + + +class SphereConstraint(Constraint): + def __init__(self, center, radius): + self.center = np.array(center) + self.radius = radius + + def is_inside(self, points, radius): + return is_inside_sphere(points=points, sphere_radius=self.radius, radius=radius) + + +@njit(cache=True, fastmath=True) +def is_inside_sphere(sphere_radius, points, radius): + n_points = points.shape[0] + results = np.empty(n_points, dtype=np.bool_) + max_distance = sphere_radius - radius + max_distance_sq = max_distance * max_distance + for i in range(n_points): + dist_from_center_sq = 0.0 + for j in range(3): + dist_from_center_sq += points[i, j] * points[i, j] + results[i] = dist_from_center_sq < max_distance_sq + return results + + +@njit(cache=True, fastmath=True) +def is_inside_cuboid(mins, maxs, points, radius): + n_points = points.shape[0] + results = np.empty(n_points, dtype=np.bool_) + for i in range(n_points): + inside = True + for j in range(3): + if points[i, j] - radius < mins[j] or points[i, j] + radius > maxs[j]: + inside = False + break + results[i] = inside + return results From fccce04990ab7dc33ebe96248e57ebae7ef6b8c4 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sat, 9 Aug 2025 17:12:04 +0100 Subject: [PATCH 092/123] Add unit test, CylinderConstraint class --- mbuild/path.py | 4 +-- mbuild/tests/test_path.py | 52 ++++++++++++++++++++++++++++--- mbuild/utils/volumes.py | 64 +++++++++++++++++++++++++++++++++------ 3 files changed, 104 insertions(+), 16 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index a6f5f1b54..7b264b055 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -221,7 +221,7 @@ def generate(self): ) if self.volume_constraint: is_inside_mask = self.volume_constraint.is_inside( - points=new_xyzs, radius=self.radius + points=new_xyzs, particle_radius=self.radius ) new_xyzs = new_xyzs[is_inside_mask] new_xyz_found = False @@ -260,7 +260,7 @@ def generate(self): ) if self.volume_constraint: is_inside_mask = self.volume_constraint.is_inside( - points=new_xyzs, radius=self.radius + points=new_xyzs, particle_radius=self.radius ) new_xyzs = new_xyzs[is_inside_mask] for xyz in new_xyzs: diff --git a/mbuild/tests/test_path.py b/mbuild/tests/test_path.py index 3927a85e4..477aef2cc 100644 --- a/mbuild/tests/test_path.py +++ b/mbuild/tests/test_path.py @@ -1,9 +1,14 @@ +import networkx as nx import numpy as np from mbuild.path import HardSphereRandomWalk from mbuild.tests.base_test import BaseTest from mbuild.utils.geometry import bounding_box -from mbuild.utils.volumes import CuboidConstraint +from mbuild.utils.volumes import ( + CuboidConstraint, + CylinderConstraint, + SphereConstraint, +) class TestRandomWalk(BaseTest): @@ -16,8 +21,14 @@ def test_random_walk(self): max_angle=np.pi, max_attempts=1e4, seed=14, + bond_graph=nx.path_graph(20), ) assert len(rw_path.coordinates) == 20 + diffs = rw_path.coordinates[0:-2] - rw_path.coordinates[1:-1] + assert np.allclose(0.25, np.linalg.norm(diffs, axis=1), atol=1e-4) + comp = rw_path.to_compound() + assert comp.n_particles == 20 + assert comp.n_bonds == 19 def test_seeds(self): rw_path_1 = HardSphereRandomWalk( @@ -38,7 +49,7 @@ def test_seeds(self): max_attempts=1e4, seed=14, ) - assert np.allclose(rw_path_1.coordinates, rw_path_2.coordinates, atol=1e-4) + assert np.allclose(rw_path_1.coordinates, rw_path_2.coordinates, atol=1e-7) def test_from_path(self): rw_path = HardSphereRandomWalk( @@ -63,11 +74,12 @@ def test_from_path(self): start_from_path_index=-1, ) assert len(rw_path2.coordinates) == 40 + assert np.array_equal(rw_path.coordinates, rw_path2.coordinates[:20]) def test_walk_inside_cube(self): cube = CuboidConstraint(Lx=5, Ly=5, Lz=5) rw_path = HardSphereRandomWalk( - N=50, + N=500, bond_length=0.25, radius=0.22, volume_constraint=cube, @@ -77,4 +89,36 @@ def test_walk_inside_cube(self): seed=14, ) bounds = bounding_box(rw_path.coordinates) - assert np.all(bounds < np.array([5, 5, 5])) + assert np.all(bounds < np.array([5 - 0.44, 5 - 0.44, 5 - 0.44])) + + def test_walk_inside_sphere(self): + sphere = SphereConstraint(radius=4, center=(2, 2, 2)) + rw_path = HardSphereRandomWalk( + N=100, + bond_length=0.25, + radius=0.22, + volume_constraint=sphere, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + seed=14, + ) + bounds = bounding_box(rw_path.coordinates) + assert np.all(bounds < np.array([(2 * 4) - 0.22])) + + def test_walk_inside_cylinder(self): + cylinder = CylinderConstraint(radius=3, height=6, center=(1.5, 1.5, 3)) + rw_path = HardSphereRandomWalk( + N=100, + bond_length=0.25, + radius=0.22, + volume_constraint=cylinder, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + seed=14, + ) + bounds = bounding_box(rw_path.coordinates) + assert bounds[0][0] < 6 - 0.22 * 2 + assert bounds[1][1] < 6 - 0.22 * 2 + assert bounds[2][2] < 6 - 0.22 * 2 diff --git a/mbuild/utils/volumes.py b/mbuild/utils/volumes.py index 4760f0202..ef48f4bc3 100644 --- a/mbuild/utils/volumes.py +++ b/mbuild/utils/volumes.py @@ -14,13 +14,16 @@ def is_inside(self, point): class CuboidConstraint(Constraint): def __init__(self, Lx, Ly, Lz, center=(0, 0, 0)): self.center = np.asarray(center) - self.mins = self.center - np.array([Lx, Ly, Lz]) - self.maxs = self.center + np.array([Lx, Ly, Lz]) + self.mins = self.center - np.array([Lx / 2, Ly / 2, Lz / 2]) + self.maxs = self.center + np.array([Lx / 2, Ly / 2, Lz / 2]) # Point to actual method to use - def is_inside(self, points, radius): + def is_inside(self, points, particle_radius): return is_inside_cuboid( - mins=self.mins, maxs=self.maxs, points=points, radius=radius + mins=self.mins, + maxs=self.maxs, + points=points, + particle_radius=particle_radius, ) @@ -29,15 +32,53 @@ def __init__(self, center, radius): self.center = np.array(center) self.radius = radius - def is_inside(self, points, radius): - return is_inside_sphere(points=points, sphere_radius=self.radius, radius=radius) + def is_inside(self, points, particle_radius): + return is_inside_sphere( + points=points, sphere_radius=self.radius, particle_radius=particle_radius + ) + + +class CylinderConstraint(Constraint): + def __init__(self, center, radius, height): + self.center = np.array(center) + self.height = height + self.radius = radius + + def is_inside(self, points, particle_radius): + return is_inside_cylinder( + points=points, + center=self.center, + cylinder_radius=self.radius, + height=self.height, + particle_radius=particle_radius, + ) + + +@njit(cache=True, fastmath=True) +def is_inside_cylinder(points, center, cylinder_radius, height, particle_radius): + n_points = points.shape[0] + results = np.empty(n_points, dtype=np.bool_) + max_r = cylinder_radius - particle_radius + max_r_sq = max_r * max_r # Radial limit squared + half_height = height / 2.0 + max_z = half_height - particle_radius + # Shift to center + for i in range(n_points): + dx = points[i, 0] - center[0] + dy = points[i, 1] - center[1] + dz = points[i, 2] - center[2] + r_sq = dx * dx + dy * dy + inside_radial = r_sq <= max_r_sq + inside_z = abs(dz) <= max_z + results[i] = inside_radial and inside_z + return results @njit(cache=True, fastmath=True) -def is_inside_sphere(sphere_radius, points, radius): +def is_inside_sphere(sphere_radius, points, particle_radius): n_points = points.shape[0] results = np.empty(n_points, dtype=np.bool_) - max_distance = sphere_radius - radius + max_distance = sphere_radius - particle_radius max_distance_sq = max_distance * max_distance for i in range(n_points): dist_from_center_sq = 0.0 @@ -48,13 +89,16 @@ def is_inside_sphere(sphere_radius, points, radius): @njit(cache=True, fastmath=True) -def is_inside_cuboid(mins, maxs, points, radius): +def is_inside_cuboid(mins, maxs, points, particle_radius): n_points = points.shape[0] results = np.empty(n_points, dtype=np.bool_) for i in range(n_points): inside = True for j in range(3): - if points[i, j] - radius < mins[j] or points[i, j] + radius > maxs[j]: + if ( + points[i, j] - particle_radius < mins[j] + or points[i, j] + particle_radius > maxs[j] + ): inside = False break results[i] = inside From 211183021211d10917a2b5d19cd7a849c1d28db7 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Sat, 9 Aug 2025 17:53:20 +0100 Subject: [PATCH 093/123] start method for choosing initial point --- mbuild/path.py | 21 +++++++++++++++++++++ mbuild/utils/volumes.py | 18 +++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/mbuild/path.py b/mbuild/path.py index 7b264b055..0d976aac3 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -294,6 +294,27 @@ def _generate_random_trials(self): ) return thetas, r + def _initial_point(self): + # Random initial point, bounds set by radius and N steps + if not any([self.volume_constraint, self.initial_point, self.start_from_path]): + max_dist = self.N * self.radius + coord = self.rng.uniform(low=-max_dist, high=max_dist, size=3) + return coord + + # Random point inside volume constraint, not starting from another path + elif self.volume_constraint and not self.start_from_path_index: + coord = self.rng.uniform( + low=self.volume_constraint.mins, + high=self.volume_constraint.maxs, + size=3, + ) + return coord + + # Starting from another path, get accepted next move + elif self.start_from_path and self.start_from_path_index: + pass + # + class Lamellar(Path): """Generate a 2-D or 3-D lamellar-like path. diff --git a/mbuild/utils/volumes.py b/mbuild/utils/volumes.py index ef48f4bc3..c1cd3a999 100644 --- a/mbuild/utils/volumes.py +++ b/mbuild/utils/volumes.py @@ -17,8 +17,8 @@ def __init__(self, Lx, Ly, Lz, center=(0, 0, 0)): self.mins = self.center - np.array([Lx / 2, Ly / 2, Lz / 2]) self.maxs = self.center + np.array([Lx / 2, Ly / 2, Lz / 2]) - # Point to actual method to use def is_inside(self, points, particle_radius): + """Points are particle_radius are passed in from HardSphereRandomWalk""" return is_inside_cuboid( mins=self.mins, maxs=self.maxs, @@ -31,6 +31,8 @@ class SphereConstraint(Constraint): def __init__(self, center, radius): self.center = np.array(center) self.radius = radius + self.mins = self.center - self.radius + self.maxs = self.center + self.radius def is_inside(self, points, particle_radius): return is_inside_sphere( @@ -43,6 +45,20 @@ def __init__(self, center, radius, height): self.center = np.array(center) self.height = height self.radius = radius + self.mins = np.array( + [ + self.center[0] - self.radius, + self.center[1] - self.radius, + self.center[2] - self.height / 2, + ] + ) + self.maxs = np.array( + [ + self.center[0] + self.radius, + self.center[1] + self.radius, + self.center[2] + self.height / 2, + ] + ) def is_inside(self, points, particle_radius): return is_inside_cylinder( From 63d414db529c2e798fb0d400ec2babedcf16b886 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 11 Aug 2025 15:55:33 +0100 Subject: [PATCH 094/123] Add ability to include compounds in hardsphere random walk --- mbuild/path.py | 182 ++++++++++++++++++++++++-------------- mbuild/tests/test_path.py | 23 ++++- mbuild/utils/volumes.py | 4 +- 3 files changed, 140 insertions(+), 69 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index 0d976aac3..c693eed80 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -105,11 +105,13 @@ def __init__( min_angle, max_angle, volume_constraint=None, + start_from_path=None, + start_from_path_index=None, + initial_point=None, + include_compound=None, max_attempts=1e5, seed=42, trial_batch_size=20, - start_from_path=None, - start_from_path_index=None, tolerance=1e-5, bond_graph=None, ): @@ -148,6 +150,11 @@ def __init__( bond_graph : networkx.graph.Graph; optional Sets the bonding of sites along the path. """ + if initial_point is not None: + self.initial_point = np.asarray(initial_point) + else: + self.initial_point = None + self.include_compound = include_compound self.bond_length = bond_length self.radius = radius self.min_angle = min_angle @@ -162,7 +169,7 @@ def __init__( self.start_from_path = start_from_path # This random walk is including a previous path - if start_from_path and start_from_path_index is not None: + if start_from_path: coordinates = np.concatenate( ( start_from_path.coordinates.astype(np.float32), @@ -174,9 +181,6 @@ def __init__( N = None else: # Not starting from another path coordinates = np.zeros((N, 3), dtype=np.float32) - # If volume constraint; can't always start with (0,0,0) - if self.volume_constraint: - coordinates[0] = self.volume_constraint.center self.count = 0 self.start_index = 0 # Need this for error message about reaching max tries @@ -192,8 +196,11 @@ def __init__( ) def generate(self): - if not self.start_from_path: - # With fixed bond lengths, first move is always accepted + initial_xyz = self._initial_points() + if not self.start_from_path and not self.volume_constraint: + # Set the first coordinate + self.coordinates[0] = initial_xyz + # If no volume constraint, then first move is always accepted phi = self.rng.uniform(0, 2 * np.pi) theta = self.rng.uniform(0, np.pi) next_pos = np.array( @@ -204,50 +211,42 @@ def generate(self): ] ) self.coordinates[1] = self.coordinates[0] + next_pos - self.count += 1 # We already have 1 accepted move - else: # Start random walk from a previous set of coordinates - started_next_path = False - while not started_next_path: - batch_angles, batch_vectors = self._generate_random_trials() - new_xyzs = self.next_coordinate( - pos1=self.start_from_path.coordinates[self.start_from_path_index], - pos2=self.start_from_path.coordinates[ - self.start_from_path_index - 1 - ], - bond_length=self.bond_length, - thetas=batch_angles, - r_vectors=batch_vectors, - batch_size=self.trial_batch_size, + self.count += 1 + + # Not starting from another path, but have a volume constraint + # Possible for second point to be out-of-bounds + elif not self.start_from_path and self.volume_constraint: + self.coordinates[0] = initial_xyz + next_point_found = False + while not next_point_found: + phi = self.rng.uniform(0, 2 * np.pi) + theta = self.rng.uniform(0, np.pi) + xyz = np.array( + [ + self.bond_length * np.sin(theta) * np.cos(phi), + self.bond_length * np.sin(theta) * np.sin(phi), + self.bond_length * np.cos(theta), + ] ) - if self.volume_constraint: - is_inside_mask = self.volume_constraint.is_inside( - points=new_xyzs, particle_radius=self.radius - ) - new_xyzs = new_xyzs[is_inside_mask] - new_xyz_found = False - for xyz in new_xyzs: - if self.check_path( - existing_points=self.coordinates[: self.count + 1], - new_point=xyz, - radius=self.radius, - tolerance=self.tolerance, - ): - self.coordinates[self.count + 1] = xyz - self.count += 1 - self.attempts += 1 - new_xyz_found = True - started_next_path = True - break - if not new_xyz_found: + is_inside_mask = self.volume_constraint.is_inside( + points=np.array([xyz]), particle_radius=self.radius + ) + if np.all(is_inside_mask): + self.coordinates[1] = self.coordinates[0] + xyz + self.count += 1 self.attempts += 1 + next_point_found = True + # 2nd point failed, continue while loop + self.attempts += 1 - if self.attempts == self.max_attempts and self.count < self.N: - raise RuntimeError( - "The maximum number attempts allowed have passed, and only ", - f"{self.count - self._init_count} sucsessful attempts were completed.", - "Try changing the parameters and running again.", - ) + # Starting random walk from a previous set of coordinates + # This point was accepted in self._initial_point with these conditions + else: + self.coordinates[self.count + 1] = initial_xyz + self.count += 1 + self.attempts += 1 + # Initial conditions set (points 1 and 2), now start RW with min/max angles while self.count < self.N - 1: batch_angles, batch_vectors = self._generate_random_trials() new_xyzs = self.next_coordinate( @@ -264,8 +263,14 @@ def generate(self): ) new_xyzs = new_xyzs[is_inside_mask] for xyz in new_xyzs: + if self.include_compound: + existing_points = np.concatenate( + (self.coordinates[: self.count + 1], self.include_compound.xyz) + ) + else: + existing_points = self.coordinates[: self.count + 1] if self.check_path( - existing_points=self.coordinates[: self.count + 1], + existing_points=existing_points, new_point=xyz, radius=self.radius, tolerance=self.tolerance, @@ -294,26 +299,75 @@ def _generate_random_trials(self): ) return thetas, r - def _initial_point(self): + def _initial_points(self): + """Choosing first and second points depends on the parameters passed in.""" + # Use manually specified initial point + if self.initial_point is not None: + return self.initial_point + # Random initial point, bounds set by radius and N steps - if not any([self.volume_constraint, self.initial_point, self.start_from_path]): - max_dist = self.N * self.radius - coord = self.rng.uniform(low=-max_dist, high=max_dist, size=3) - return coord + elif not any( + [self.volume_constraint, self.initial_point, self.start_from_path] + ): + max_dist = (self.N * self.radius) - self.radius + xyz = self.rng.uniform(low=-max_dist, high=max_dist, size=3) + return xyz # Random point inside volume constraint, not starting from another path elif self.volume_constraint and not self.start_from_path_index: - coord = self.rng.uniform( - low=self.volume_constraint.mins, - high=self.volume_constraint.maxs, + xyz = self.rng.uniform( + low=self.volume_constraint.mins + self.radius, + high=self.volume_constraint.maxs - self.radius, size=3, ) - return coord + return xyz - # Starting from another path, get accepted next move + # Starting from another path, run Monte Carlo + # Accepted next move is first point of this random walk elif self.start_from_path and self.start_from_path_index: - pass - # + started_next_path = False + while not started_next_path: + batch_angles, batch_vectors = self._generate_random_trials() + new_xyzs = self.next_coordinate( + pos1=self.start_from_path.coordinates[self.start_from_path_index], + pos2=self.start_from_path.coordinates[ + self.start_from_path_index - 1 + ], + bond_length=self.bond_length, + thetas=batch_angles, + r_vectors=batch_vectors, + batch_size=self.trial_batch_size, + ) + if self.volume_constraint: + is_inside_mask = self.volume_constraint.is_inside( + points=new_xyzs, particle_radius=self.radius + ) + new_xyzs = new_xyzs[is_inside_mask] + for xyz in new_xyzs: + if self.include_compound: + existing_points = np.concatenate( + ( + self.coordinates[: self.count + 1], + self.include_compound.xyz, + ) + ) + else: + existing_points = self.coordinates[: self.count + 1] + if self.check_path( + existing_points=existing_points, + new_point=xyz, + radius=self.radius, + tolerance=self.tolerance, + ): + return xyz + self.attempts += 1 + + if self.attempts == self.max_attempts and self.count < self.N: + raise RuntimeError( + "The maximum number attempts allowed have passed, and only ", + f"{self.count - self._init_count} sucsessful attempts were completed.", + "Try changing the parameters and running again.", + ) class Lamellar(Path): @@ -321,13 +375,13 @@ class Lamellar(Path): Parameters ---------- + spacing : float (nm), required + The distance between two adjacent sites in the path. num_layers : int, required The number of times the lamellar path curves around creating another layer. layer_separation : float (nm), required The distance between any two layers. - spacing : float (nm), required - The distance between two sites along the path. num_stacks : int, required The number of times to repeat each layer in the Z direction. Setting this to 1 creates a single, 2D lamellar-like path. @@ -436,7 +490,7 @@ class StraightLine(Path): N : int, required The number of sites in the path. direction : array-like (1,3), default = (1,0,0) - The direction to align the path along. + The direction to align the straight path along. """ def __init__(self, spacing, N, direction=(1, 0, 0), bond_graph=None): diff --git a/mbuild/tests/test_path.py b/mbuild/tests/test_path.py index 477aef2cc..436749d47 100644 --- a/mbuild/tests/test_path.py +++ b/mbuild/tests/test_path.py @@ -29,6 +29,21 @@ def test_random_walk(self): comp = rw_path.to_compound() assert comp.n_particles == 20 assert comp.n_bonds == 19 + # Test bounds of random initial point + assert np.all(rw_path.coordinates[0]) < 20 * 0.22 + + def test_set_initial_point(self): + rw_path = HardSphereRandomWalk( + N=20, + bond_length=0.25, + radius=0.22, + min_angle=np.pi / 4, + max_angle=np.pi, + max_attempts=1e4, + initial_point=(1, 2, 3), + seed=14, + ) + assert np.array_equal(rw_path.coordinates[0], np.array([1, 2, 3])) def test_seeds(self): rw_path_1 = HardSphereRandomWalk( @@ -61,7 +76,6 @@ def test_from_path(self): max_attempts=1e4, seed=14, ) - rw_path2 = HardSphereRandomWalk( N=20, bond_length=0.25, @@ -94,14 +108,15 @@ def test_walk_inside_cube(self): def test_walk_inside_sphere(self): sphere = SphereConstraint(radius=4, center=(2, 2, 2)) rw_path = HardSphereRandomWalk( - N=100, + N=200, bond_length=0.25, radius=0.22, volume_constraint=sphere, + initial_point=(0, 0, 0), min_angle=np.pi / 4, max_angle=np.pi, max_attempts=1e4, - seed=14, + seed=90, ) bounds = bounding_box(rw_path.coordinates) assert np.all(bounds < np.array([(2 * 4) - 0.22])) @@ -109,7 +124,7 @@ def test_walk_inside_sphere(self): def test_walk_inside_cylinder(self): cylinder = CylinderConstraint(radius=3, height=6, center=(1.5, 1.5, 3)) rw_path = HardSphereRandomWalk( - N=100, + N=200, bond_length=0.25, radius=0.22, volume_constraint=cylinder, diff --git a/mbuild/utils/volumes.py b/mbuild/utils/volumes.py index c1cd3a999..4f8dbeec4 100644 --- a/mbuild/utils/volumes.py +++ b/mbuild/utils/volumes.py @@ -18,7 +18,7 @@ def __init__(self, Lx, Ly, Lz, center=(0, 0, 0)): self.maxs = self.center + np.array([Lx / 2, Ly / 2, Lz / 2]) def is_inside(self, points, particle_radius): - """Points are particle_radius are passed in from HardSphereRandomWalk""" + """Points and particle_radius are passed in from HardSphereRandomWalk""" return is_inside_cuboid( mins=self.mins, maxs=self.maxs, @@ -35,6 +35,7 @@ def __init__(self, center, radius): self.maxs = self.center + self.radius def is_inside(self, points, particle_radius): + """Points and particle_radius are passed in from HardSphereRandomWalk""" return is_inside_sphere( points=points, sphere_radius=self.radius, particle_radius=particle_radius ) @@ -61,6 +62,7 @@ def __init__(self, center, radius, height): ) def is_inside(self, points, particle_radius): + """Points and particle_radius are passed in from HardSphereRandomWalk""" return is_inside_cylinder( points=points, center=self.center, From 17af223c25d246ed6d07415aae0e540e15d5d908 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 22:18:14 +0000 Subject: [PATCH 095/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.7 → v0.12.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.7...v0.12.8) - [github.com/pre-commit/pre-commit-hooks: v5.0.0 → v6.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v5.0.0...v6.0.0) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 088b92d99..ea4be9884 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.7 + rev: v0.12.8 hooks: # Run the linter. - id: ruff @@ -18,7 +18,7 @@ repos: # Run the formatter. - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-yaml - id: end-of-file-fixer From 416ce0c531c896ddff4c3260d48704bca6493e2e Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 12 Aug 2025 10:42:04 +0100 Subject: [PATCH 096/123] add doc strings --- mbuild/path.py | 21 +++++++++++++++++++-- mbuild/polymer.py | 5 +++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index c693eed80..e1b7a21a2 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -54,7 +54,7 @@ def generate(self): """ pass - def neighbor_list(self, r_max, coordinates=None, box=None): + def neighbor_list(self, r_max, query_points, coordinates=None, box=None): """Use freud to create a neighbor list of a set of coordinates.""" if coordinates is None: coordinates = self.coordinates @@ -63,7 +63,7 @@ def neighbor_list(self, r_max, coordinates=None, box=None): freud_box = freud.box.Box(Lx=box[0], Ly=box[1], Lz=box[2]) aq = freud.locality.AABBQuery(freud_box, coordinates) aq_query = aq.query( - query_points=coordinates, + query_points=query_points, query_args=dict(r_min=0.0, r_max=r_max, exclude_ii=True), ) nlist = aq_query.toNeighborList() @@ -140,6 +140,8 @@ def __init__( The number of trial moves to attempt in parallel for each step. Using larger values can improve success rates for more dense random walks. + max_attempts : int, defualt = 1e5 + The maximum number of trial moves to attempt before quiting. start_from_path : mbuild.path.Path, optional An instance of a previous Path to start the random walk from. start_from_path_index : int, optional @@ -149,6 +151,21 @@ def __init__( Tolerance used for rounding and checkig for overlaps. bond_graph : networkx.graph.Graph; optional Sets the bonding of sites along the path. + + Notes + ----- + Each next-move can be attempted in batches, set by the ``trial_batch_size`` + parameter. The batch size moves do not count towards the maximum allowed + attemps. For example, 1 random walk with a trail batch size of 20 counts at + only one attempted move. Larger values of ``trial_batch_size`` may help + highly constrained walks finish, but may hurt performance. + + You can start a random walk from a previously created path with the + ``start_from_path`` and ``start_from_path_index`` parameters. For example, + create a ``Lamellar`` path and run a random walk from its last index to begin + generating a semi-crystalline-like structure. Or, string together multiple + HardSphereRandomWalk paths where the final result of each is passed into the + next random walk. """ if initial_point is not None: self.initial_point = np.asarray(initial_point) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 809e1cddf..80fdc9eeb 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -45,6 +45,11 @@ class Polymer(Compound): add_end_groups(compound, index, separation, orientation, replace) Use to add an end group compound to Polymer.end_groups + build_from_path(path, sequence, add_hydrogens, bond_head_tail, energy_minimize) + Used to set the polymer configuration from a pre-determined mbuild.path.Path + Use this to create polymers that follow a random walk, or form + lamellar order, ring polymers and more. See the ``mbuild.path`` module. + build(n, sequence) Use to create a single polymer compound. This method uses the compounds created by calling the add_monomer and add_end_group methods. From 036bffde9c4d7695cbbb10613ad233bfcc20aab6 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 13 Aug 2025 11:59:21 +0100 Subject: [PATCH 097/123] Lots of doc string and code comments added --- mbuild/polymer.py | 163 ++++++++++++++++++++++++++----------------- mbuild/simulation.py | 152 +++++++++++++++++++++++++++++++++------- 2 files changed, 226 insertions(+), 89 deletions(-) diff --git a/mbuild/polymer.py b/mbuild/polymer.py index 80fdc9eeb..f9ed00a78 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -129,23 +129,78 @@ def end_groups(self): """ return self._end_groups - @property - def contour_length(self): - """The contour length (nm) of the polymer chain.""" - return sum([length for length in self.backbone_bond_lengths()]) - - def backbone_vectors(self): - """Yield the consecutive monomer-monomer vectors.""" - for i, mon in enumerate(self.children): - try: - yield self.children[i + 1].center - mon.center - except IndexError: - pass - - def backbone_bond_lengths(self): - """Yield lengths (nm) of consecutive monomer-monomer vectors.""" - for vec in self.backbone_vectors(): - yield np.linalg.norm(vec) + def build_from_path( + self, + path, + sequence="A", + add_hydrogens=True, + bond_head_tail=False, + energy_minimize=True, + ): + """Build the polymer chain to a pre-defined configuraiton. + + See ``mbuild.path.Path`` to available polymer configurations + or use mbuild.path.Path.from_coordinates() to manually set + a polymer chan configuraiton. + + Parameters + ---------- + path : mbuild.path.Path, required + The configuration the polymer will be mapped to after connecting + all of the components (monomers and end groups). + The path will determine the number of monomer sites. + sequence : str, optional, default 'A' + A string of characters where each unique character represents one + repetition of a monomer. Characters in `sequence` are assigned to + monomers in the order they appear in `Polymer.monomers`. + The characters in `sequence` are assigned to the compounds in the + in the order that they appear in the Polymer.monomers list. + For example, 'AB' where 'A'corresponds to the first compound + added to Polymer.monomers and 'B' to the second compound. + add_hydrogens : bool, default True + If True and an ``end_groups`` compound is None, then the head or tail + of the polymer will be capped off with hydrogen atoms. If end group + compounds exist, then they will be used. + If False and an end group compound is None, then the head or tail + port will be exposed in the polymer. + bond_head_tail : bool, default False + If set to ``True``, then a bond between the head and tail groups (monomers or end groups) + is created. This does not create a periodic bond (see Polymer.create_periodic_bond). + The ``add_hydrogens`` parameter must be set to ``False`` to create this bond. + This is useful for creating ring polymers, knot polymers. + See ``mbuild.path.Cyclic`` and ``mbuild.path.Knot``. + energy_minimize : bool, default True + If ``True`` then relax the bonds and angles that may be distorted from mapping + the atomistic polymer to a path. + This uses the capped displacement methods in ``mbuild.simulation``. + + Notes + ----- + Energy Minimization: + + It is required to run energy minimization on chains built from a path to relax + the topology to a suitable starting point. + mBuild contains multiple energy minimization approaches in the ``mbuild.simulation`` module. + + When ``energy_minimize`` is set to ``True``, this method uses the OpenBabel based energy + minimization method with the UFF force field. We have found that once polymers + reach a size on the order of ~500 atoms, significantly faster energy minimization can be + achieved using one of the hoomd-based methods in ``mbuild.simulation``. + In that case, set ``energy_minimize=False`` and pass the resulting polymer + compound into one of these methods. + See: ``mbuild.simulation.hoomd_cap_displacement``, and ``mbuild.simulation.hoomd_fire``. + + """ + n = len(path.coordinates) - sum([1 for i in self.end_groups if i is not None]) + self.build( + n=n, + sequence=sequence, + add_hydrogens=add_hydrogens, + bond_head_tail=bond_head_tail, + ) + self.set_monomer_positions( + coordinates=path.coordinates, energy_minimize=energy_minimize + ) def set_monomer_positions(self, coordinates, energy_minimize=True): """Shift monomers so that their center of mass matches a set of pre-defined coordinates. @@ -154,30 +209,28 @@ def set_monomer_positions(self, coordinates, energy_minimize=True): ---------- coordinates : np.ndarray, shape=(N,3) Set of x,y,z coordinatess + + Notes + ----- + Energy Minimization: + + It is required to run energy minimization on chains built from a path to relax + the topology to a suitable starting point. + mBuild contains multiple energy minimization approaches in the ``mbuild.simulation`` module. + + When ``energy_minimize`` is set to ``True``, this method uses the OpenBabel based energy + minimization method with the UFF force field. We have found that once polymers + reach a size on the order of ~500 atoms, significantly faster energy minimization can be + achieved using one of the hoomd-based methods in ``mbuild.simulation`` + In that case, set ``energy_minimize=False`` and pass the resulting polymer + compound into one of these methods. + See: `hoomd_cap_displacement`, and `hoomd_fire`. """ for i, xyz in enumerate(coordinates): self.children[i].translate_to(xyz) if energy_minimize: e_min(self) - def straighten(self, axis=(1, 0, 0), energy_minimize=True): - """Shift monomer positions so that the backbone is straight. - - Parameters - ---------- - axis : np.ndarray, shape=(1,3), default (1, 0, 0) - Direction to align the polymer backbone. - energy_minimize : bool, default True - If True, run energy minimization on resulting structure. - See `mbuild.Compound.energy_minimize()` - """ - axis = np.asarray(axis) - avg_bond_L = np.mean([L for L in self.backbone_bond_lengths()]) - coords = np.array( - [np.zeros(3) + i * avg_bond_L * axis for i in range(len(self.children))] - ) - self.set_monomer_positions(coordinates=coords, energy_minimize=energy_minimize) - def build(self, n, sequence="A", add_hydrogens=True, bond_head_tail=False): """Connect one or more components in a specified sequence. @@ -206,15 +259,18 @@ def build(self, n, sequence="A", add_hydrogens=True, bond_head_tail=False): compounds exist, then they will be used. If False and an end group compound is None, then the head or tail port will be exposed in the polymer. - periodic_axis : str, default None - If not ``None`` and an ``end_groups`` compound is None, then the head - and tail will be forced into an overlap with a periodicity along the - ``axis`` (default="z") specified. See - :meth:`mbuild.lib.recipes.polymer.Polymer.create_periodic_bond` for - more details. If end group compounds exist, then there will be a - warning. However ``add_hydrogens`` will simply be overwritten. - If ``None``, ``end_groups`` compound is None, and ``add_hydrogens`` is - False then the head or tail port will be exposed in the polymer. + bond_head_tail : bool, default False + If set to ``True``, then a bond between the head and tail groups (monomers or end groups) + is created. This does not create a periodic bond (see Polymer.create_periodic_bond). + The ``add_hydrogens`` parameter must be set to ``False`` to create this bond. + This is useful for creating ring polymers, knot polymers. + See ``mbuild.path.Cyclic`` and ``mbuild.path.Knot``. + + Notes + ----- + The chain conformations obtained from this method are often difficult to use + in later steps of creating systems of polymers. See the alternative build + method ``Polymer.build_from_path``. """ if add_hydrogens and bond_head_tail: raise ValueError( @@ -309,25 +365,6 @@ def build(self, n, sequence="A", add_hydrogens=True, bond_head_tail=False): if bond_head_tail: force_overlap(self, self.head_port, self.tail_port) - def build_from_path( - self, - path, - sequence="A", - add_hydrogens=True, - bond_head_tail=False, - energy_minimize=True, - ): - n = len(path.coordinates) - sum([1 for i in self.end_groups if i is not None]) - self.build( - n=n, - sequence=sequence, - add_hydrogens=add_hydrogens, - bond_head_tail=bond_head_tail, - ) - self.set_monomer_positions( - coordinates=path.coordinates, energy_minimize=energy_minimize - ) - def add_monomer( self, compound, diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 3ef7c138a..797719147 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -17,6 +17,11 @@ class HoomdSimulation(hoomd.simulation.Simulation): + """A custom class to help in creating internal hoomd-based simulation methods. + + See ``hoomd_cap_displacement`` and ``hoomd_fire``. + """ + def __init__( self, compound, @@ -181,16 +186,88 @@ def hoomd_cap_displacement( n_steps, dt, r_cut, - max_displacement, - dpd_A, - n_relax_steps, + max_displacement=1e-3, fixed_compounds=None, integrate_compounds=None, + dpd_A=None, bond_k_scale=1, angle_k_scale=1, + n_relax_steps=0, run_on_gpu=False, seed=42, ): + """Run a short simulation with hoomd.md.methods.DisplacementCapped + + Parameters + ---------- + compound : mb.Compound + The compound to use in the simulation + forcefield : foyer.forcefield.Forcefield or gmso.core.Forcefield + The forcefield to apply to the system + n_steps : int + The number of simulation time steps to run with the modified forcefield + created by bond_k_scale, angle_k_scale, and dpd_A. + See these parameters for more information. + dt : float + The simulation timestep. Note that for running capped displacement on highly unstable + systems (e.g. overlapping particles), it is often more stable to use a larger + value of dt and let the displacement cap limit position updates. + r_cut : float (nm) + The cutoff distance (nm) used in the non-bonded pair neighborlist. + Use smaller values for unstable starting conditions and faster performance. + max_displacement : float (nm), default = 1e-3 nm + The maximum displacement (nm) allowed per timestep. Use smaller values for + highly unstable systems. + fixed_compounds : list of mb.Compound, default None + If given, these compounds will be removed from the integration updates and + held "frozen" during the hoomd simulation. + They are still able to interact with other particles in the simulation. + If desired, pass in a subset of children from `compound.children`. + integrate_compounds : list of mb.Compound, default None + If given, then only these compounds will be updated during integration + and all other compunds in `compound` are removed from the integration group. + dpd_A : float, default None + If set to a value, then the initial simulation replaces the LJ 12-6 pair potential + with the softer hoomd.md.pair.DPDConservative pair force from HOOMD. + This is used for `n_steps` and replace with the original LJ for potential which is + used for n_relax_steps. This can be useful for highly unstable starting configutations. + Note that the cutoff for the DPD force is set to the sigma value for each atom type + and force cosntant (A) is set to dpd_A for all atom types. + bond_k_scale : float, default 1 + Scale the bond-stretching force constants so that K_new = K * bond_k_scale. + This can be useful for relaxing highly unstable starting configutations. + The bond force constants are scaled back to their original values + during the run of `n_relax_steps` + angle_k_scale : float, default 1 + Scale the bond-angle force constants so that K_new = K * angle_k_scale. + This can be useful for relaxing highly unstable starting configutations. + The angle force constants are scaled back to their original values + during the run of `n_relax_steps` + n_relax_steps: int, optional + The number of steps to run after running for `n_steps` with the modified + forcefield. This is designed to be used when utilize the parameters + `dpd_A`, `bond_k_scale`, and `angle_k_scale`. + run_on_gpu : bool, default False + When `True` the HOOMD simulation uses the hoomd.device.GPU() device. + This requires that you have a GPU compatible HOOMD install and + a compatible GPU device. + seed : int, default 42 + The seed passed to HOOMD + + Notes + ----- + Running on GPU: + The hoomd-based methods in ``mbuild.simulation`` + are able to utilize and run on GPUs to perform energy minimization for larger systems if needed. + Performance improvements of using GPU over CPU may not be significant until systems reach a + size of ~1,000 particles. + + Running multiple simulations: + The information needed to run the HOOMD simulation is saved after the first simulation. + Therefore, calling hoomd-based simulation methods multiple times (e.g, in a for loop or while loop) + does not require re-running performing atom-typing, applying the forcefield, or running hoomd + format writers again. + """ compound._kick() sim = HoomdSimulation( compound=compound, @@ -204,13 +281,16 @@ def hoomd_cap_displacement( bond = sim.get_force(hoomd.md.bond.Harmonic) angle = sim.get_force(hoomd.md.angle.Harmonic) lj = sim.get_force(hoomd.md.pair.LJ) - # Scale bond K and angle K - for param in bond.params: - bond.params[param]["k"] *= bond_k_scale + # If needed scale bond K and angle K + if bond_k_scale != 1.0: + for param in bond.params: + bond.params[param]["k"] *= bond_k_scale sim.active_forces.append(bond) + # If angle_k_scale is zero, then "turn off" angles; skip adding the angle force if angle_k_scale != 0: - for param in angle.params: - angle.params[param]["k"] *= angle_k_scale + if angle_k_scale != 1.0: + for param in angle.params: + angle.params[param]["k"] *= angle_k_scale sim.active_forces.append(angle) if dpd_A: dpd = sim.get_dpd_from_lj(A=dpd_A) @@ -229,16 +309,18 @@ def hoomd_cap_displacement( if dpd_A: # Repalce DPD with original LJ for final relaxation sim.active_forces.remove(dpd) sim.active_forces.append(lj) - for param in bond.params: - bond.params[param]["k"] /= bond_k_scale - if angle_k_scale != 0: - for param in angle.params: - angle.params[param]["k"] /= angle_k_scale - else: + if bond_k_scale != 1.0: + for param in bond.params: + bond.params[param]["k"] /= bond_k_scale + if angle_k_scale != 0: # Angle force already added, rescale + if angle_k_scale != 1.0: + for param in angle.params: + angle.params[param]["k"] /= angle_k_scale + else: # Don't rescale, just add the original angle force sim.active_forces.append(angle) sim._update_integrator_forces() sim.run(n_relax_steps) - + # Update particle positions, save latest state point snapshot sim.update_positions() sim._update_snapshot() @@ -287,6 +369,20 @@ def hoomd_fire( run_on_gpu : bool, default True If true, attempts to run HOOMD-Blue on a GPU. If a GPU device isn't found, then it will run on the CPU + + Notes + ----- + Running on GPU: + The hoomd-based methods in ``mbuild.simulation`` + are able to utilize and run on GPUs to perform energy minimization for larger systems if needed. + Performance improvements of using GPU over CPU may not be significant until systems reach a + size of ~1,000 particles. + + Running multiple simulations: + The information needed to run the HOOMD simulation is saved after the first simulation. + Therefore, calling hoomd-based simulation methods multiple times (e.g, in a for loop or while loop) + does not require re-running performing atom-typing, applying the forcefield, or running hoomd + format writers again. """ compound._kick() sim = HoomdSimulation( @@ -302,13 +398,15 @@ def hoomd_fire( angle = sim.get_force(hoomd.md.angle.Harmonic) lj = sim.get_force(hoomd.md.pair.LJ) # Scale bond K and angle K - for param in bond.params: - bond.params[param]["k"] *= bond_k_scale + if bond_k_scale != 1.0: + for param in bond.params: + bond.params[param]["k"] *= bond_k_scale sim.active_forces.append(bond) # If angle_k_scale is zero, skip and don't append angle force if angle_k_scale != 0: - for param in angle.params: - angle.params[param]["k"] *= angle_k_scale + if angle_k_scale != 1.0: + for param in angle.params: + angle.params[param]["k"] *= angle_k_scale sim.active_forces.append(angle) if dpd_A: dpd = sim.get_dpd_from_lj(A=dpd_A) @@ -340,16 +438,18 @@ def hoomd_fire( if dpd_A: # Replace DPD pair force with original LJ sim.active_forces.remove(dpd) sim.active_forces.append(lj) - for param in bond.params: - bond.params[param]["k"] /= bond_k_scale - if angle_k_scale != 0: - for param in angle.params: - angle.params[param]["k"] /= angle_k_scale - else: # Include angles in final relaxation step + if bond_k_scale != 1.0: + for param in bond.params: + bond.params[param]["k"] /= bond_k_scale + if angle_k_scale != 0: # Angle force already included, rescale. + if angle_k_scale != 1.0: + for param in angle.params: + angle.params[param]["k"] /= angle_k_scale + else: # Don't recale, just add angles for final relaxation step sim.active_forces.append(angle) sim._update_integrator_forces() sim.run(n_relax_steps) - # Update particle positions + # Update particle positions, save latest state point snapshot sim._update_snapshot() sim.update_positions() From 21d5b8f2b1b52fc1c0e519a46564c7169159e17c Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 14 Aug 2025 14:45:19 +0100 Subject: [PATCH 098/123] Use particle hashes instead of coordinates in get_child_indices, update doc strings, and failing tests --- mbuild/compound.py | 7 ++++--- mbuild/path.py | 35 +++++++++++++++++++++++++---------- mbuild/simulation.py | 34 ++++++++++++++++++++++++++++++++-- mbuild/tests/test_compound.py | 21 ++++++++++++++++++--- mbuild/tests/test_polymer.py | 2 +- 5 files changed, 80 insertions(+), 19 deletions(-) diff --git a/mbuild/compound.py b/mbuild/compound.py index 7abeaeb1d..f77b86ce6 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -252,10 +252,11 @@ def get_child_indices(self, child): """ if child not in self.children: raise ValueError(f"{child} is not a child in this compound's hiearchy.") - matches = np.any( - np.all(self.xyz[:, np.newaxis, :] == child.xyz, axis=2), axis=1 + parent_hashes = np.array([p.__hash__() for p in self.particles()]) + child_hashes = np.array([p.__hash__() for p in child.particles()]) + matching_indices = list( + int(np.where(parent_hashes == i)[0][0]) for i in child_hashes ) - matching_indices = np.where(matches)[0] return matching_indices def set_bond_graph(self, new_graph): diff --git a/mbuild/path.py b/mbuild/path.py index e1b7a21a2..fc5f82ca8 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -46,6 +46,12 @@ def _extend_coordinates(self, N): self.coordinates = new_array self.N += N + def get_coordinates(self): + if isinstance(self.coordinates, list): + return np.array(self.coordinates) + else: + return self.coordinates + @abstractmethod def generate(self): """Abstract class for running a Path generation algorithm. @@ -189,7 +195,7 @@ def __init__( if start_from_path: coordinates = np.concatenate( ( - start_from_path.coordinates.astype(np.float32), + start_from_path.get_coordinates().astype(np.float32), np.zeros((N, 3), dtype=np.float32), ), axis=0, @@ -203,11 +209,11 @@ def __init__( # Need this for error message about reaching max tries self._init_count = self.count # Select methods to use for random walk + # Hard-coded for, possible to make other RW methods and pass them in self.next_coordinate = _random_coordinate_numba self.check_path = _check_path_numba # Create RNG state. self.rng = np.random.default_rng(seed) - super(HardSphereRandomWalk, self).__init__( coordinates=coordinates, N=None, bond_graph=bond_graph ) @@ -265,6 +271,8 @@ def generate(self): # Initial conditions set (points 1 and 2), now start RW with min/max angles while self.count < self.N - 1: + # Choosing angles and vectors is the only random part + # Get a batch of these once, pass them into numba functions batch_angles, batch_vectors = self._generate_random_trials() new_xyzs = self.next_coordinate( pos1=self.coordinates[self.count], @@ -281,11 +289,14 @@ def generate(self): new_xyzs = new_xyzs[is_inside_mask] for xyz in new_xyzs: if self.include_compound: + # Include compound particle coordinates in check for overlaps existing_points = np.concatenate( (self.coordinates[: self.count + 1], self.include_compound.xyz) ) else: existing_points = self.coordinates[: self.count + 1] + # Now check for overlaps for each trial point + # Stop after the first success if self.check_path( existing_points=existing_points, new_point=xyz, @@ -301,7 +312,7 @@ def generate(self): raise RuntimeError( "The maximum number attempts allowed have passed, and only ", f"{self.count - self._init_count} sucsessful attempts were completed.", - "Try changing the parameters and running again.", + "Try changing the parameters or seed and running again.", ) def _generate_random_trials(self): @@ -341,15 +352,19 @@ def _initial_points(self): # Starting from another path, run Monte Carlo # Accepted next move is first point of this random walk - elif self.start_from_path and self.start_from_path_index: + elif self.start_from_path and self.start_from_path_index is not None: + if self.start_from_path_index == 0: + pos2_coord = 1 + else: + pos2_coord = self.start_from_path_index - 1 started_next_path = False while not started_next_path: batch_angles, batch_vectors = self._generate_random_trials() new_xyzs = self.next_coordinate( - pos1=self.start_from_path.coordinates[self.start_from_path_index], - pos2=self.start_from_path.coordinates[ - self.start_from_path_index - 1 + pos1=self.start_from_path.get_coordinates()[ + self.start_from_path_index ], + pos2=self.start_from_path.get_coordinates()[pos2_coord], bond_length=self.bond_length, thetas=batch_angles, r_vectors=batch_vectors, @@ -383,7 +398,7 @@ def _initial_points(self): raise RuntimeError( "The maximum number attempts allowed have passed, and only ", f"{self.count - self._init_count} sucsessful attempts were completed.", - "Try changing the parameters and running again.", + "Try changing the parameters or seed and running again.", ) @@ -426,7 +441,7 @@ def __init__( def generate(self): layer_spacing = np.arange(0, self.layer_length, self.bond_length) - # Info needed for generating coords of the curves between layers + # Info needed for generating coords of the arc curves between layers r = self.layer_separation / 2 arc_length = r * np.pi arc_num_points = math.floor(arc_length / self.bond_length) @@ -456,7 +471,7 @@ def generate(self): ] if i != self.num_layers - 1: self.coordinates.extend(layer + arc) - else: + else: # Last layer, don't include another arc set of coordinates self.coordinates.extend(layer) if self.num_stacks > 1: first_stack_coordinates = np.copy(np.array(self.coordinates)) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 797719147..ed1d21b7e 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -20,6 +20,30 @@ class HoomdSimulation(hoomd.simulation.Simulation): """A custom class to help in creating internal hoomd-based simulation methods. See ``hoomd_cap_displacement`` and ``hoomd_fire``. + + Parameters + ---------- + compound : mb.Compound + The compound to use in the simulation + forcefield : foyer.forcefield.Forcefield or gmso.core.Forcefield + The forcefield to apply to the system + r_cut : float (nm) + The cutoff distance (nm) used in the non-bonded pair neighborlist. + Use smaller values for unstable starting conditions and faster performance. + fixed_compounds : list of mb.Compound, default None + If given, these compounds will be removed from the integration updates and + held "frozen" during the hoomd simulation. + They are still able to interact with other particles in the simulation. + If desired, pass in a subset of children from `compound.children`. + integrate_compounds : list of mb.Compound, default None + If given, then only these compounds will be updated during integration + and all other compunds in `compound` are removed from the integration group. + run_on_gpu : bool, default False + When `True` the HOOMD simulation uses the hoomd.device.GPU() device. + This requires that you have a GPU compatible HOOMD install and + a compatible GPU device. + seed : int, default 42 + The seed passed to HOOMD """ def __init__( @@ -29,6 +53,8 @@ def __init__( r_cut, run_on_gpu, seed, + automatic_box=False, + box_buffer=1.5, integrate_compounds=None, fixed_compounds=None, gsd_file_name=None, @@ -50,6 +76,10 @@ def __init__( self.r_cut = r_cut self.integrate_compounds = integrate_compounds self.fixed_compounds = fixed_compounds + self.automatic_box = automatic_box + self.box_buffer = box_buffer + # TODO + # self.set_box # Check if a hoomd sim method has been used on this compound already if compound._hoomd_data: last_snapshot, last_forces, last_forcefield = compound._get_sim_data() @@ -229,7 +259,7 @@ def hoomd_cap_displacement( dpd_A : float, default None If set to a value, then the initial simulation replaces the LJ 12-6 pair potential with the softer hoomd.md.pair.DPDConservative pair force from HOOMD. - This is used for `n_steps` and replace with the original LJ for potential which is + This is used for `n_steps` and replaced with the original LJ for potential which is used for n_relax_steps. This can be useful for highly unstable starting configutations. Note that the cutoff for the DPD force is set to the sigma value for each atom type and force cosntant (A) is set to dpd_A for all atom types. @@ -245,7 +275,7 @@ def hoomd_cap_displacement( during the run of `n_relax_steps` n_relax_steps: int, optional The number of steps to run after running for `n_steps` with the modified - forcefield. This is designed to be used when utilize the parameters + forcefield. This is designed to be used when utilizing the parameters `dpd_A`, `bond_k_scale`, and `angle_k_scale`. run_on_gpu : bool, default False When `True` the HOOMD simulation uses the hoomd.device.GPU() device. diff --git a/mbuild/tests/test_compound.py b/mbuild/tests/test_compound.py index a0d03cd79..cd5b551bd 100644 --- a/mbuild/tests/test_compound.py +++ b/mbuild/tests/test_compound.py @@ -90,6 +90,20 @@ def test_save_ports(self): assert mol_in.n_particles == 9 + def test_get_child_indices(self): + methane = mb.load("C", smiles=True) + methane2 = mb.clone(methane) + ethane = mb.load("CC", smiles=True) + comp = Compound(subcompounds=[methane, ethane, methane2]) + assert comp.get_child_indices(child=methane) == [0, 1, 2, 3, 4] + assert comp.get_child_indices(child=ethane) == [5, 6, 7, 8, 9, 10, 11, 12] + assert comp.get_child_indices(child=methane2) == [13, 14, 15, 16, 17] + assert methane.get_child_indices(child=methane.children[0]) == [0] + assert ethane.get_child_indices(child=ethane.children[0]) == [0] + + with pytest.raises(ValueError): + ethane.get_child_indices(child=methane.children[0]) + def test_load_xyz(self): myethane = mb.load(get_fn("ethane.xyz")) assert myethane.n_particles == 8 @@ -2317,9 +2331,10 @@ def test_xyz_setter(self): # This fails prior to applying PR # 892 ff.apply(ethane) - def test_ordered_bonds(self): - ethane = mb.load("CC", smiles=True) - ethane2 = mb.load("CC", smiles=True) + def test_ordered_bonds(self, ethane): + ethane2 = mb.clone(ethane) + # ethane = mb.load("CC", smiles=True) + # ethane2 = mb.load("CC", smiles=True) for bond2, bond in zip(ethane2.bonds(), ethane.bonds()): assert bond2[0].name == bond[0].name assert all(bond2[0].pos == bond[0].pos) diff --git a/mbuild/tests/test_polymer.py b/mbuild/tests/test_polymer.py index 85b15d18f..80fa9640b 100644 --- a/mbuild/tests/test_polymer.py +++ b/mbuild/tests/test_polymer.py @@ -31,8 +31,8 @@ def test_pass_end_groups(self, ch2, ester): ester_2 = mb.clone(ester) c6 = Polymer(monomers=[ch2], end_groups=[ester, ester_2]) c6.build(n=6) + assert c6.children[0].name == "Ester" assert c6.children[-1].name == "Ester" - assert c6.children[-2].name == "Ester" def test_errors(self, ch2, ester): with pytest.raises(ValueError): # Not enough end groups From 6076f3c044eb1fad7a9a6776a220ca0beaf98193 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 14 Aug 2025 14:53:34 +0100 Subject: [PATCH 099/123] Remove changes in load from rdkit smiles that broke unit tests --- mbuild/conversion.py | 2 +- mbuild/tests/test_compound.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/mbuild/conversion.py b/mbuild/conversion.py index 06bcc878a..41fba445e 100644 --- a/mbuild/conversion.py +++ b/mbuild/conversion.py @@ -835,7 +835,7 @@ def from_rdkit(rdkit_mol, compound=None, coords_only=False, smiles_seed=0): "to the RDKit error messages for possible fixes. You can also " "install openbabel and use the backend='pybel' instead" ) - AllChem.EmbedMolecule(mymol, useExpTorsionAnglePrefs=True, useBasicKnowledge=True) + # AllChem.EmbedMolecule(mymol, useExpTorsionAnglePrefs=True, useBasicKnowledge=True) AllChem.UFFOptimizeMolecule(mymol) single_mol = mymol.GetConformer(0) # convert from Angstroms to nanometers diff --git a/mbuild/tests/test_compound.py b/mbuild/tests/test_compound.py index cd5b551bd..d87defeab 100644 --- a/mbuild/tests/test_compound.py +++ b/mbuild/tests/test_compound.py @@ -2331,10 +2331,9 @@ def test_xyz_setter(self): # This fails prior to applying PR # 892 ff.apply(ethane) - def test_ordered_bonds(self, ethane): - ethane2 = mb.clone(ethane) - # ethane = mb.load("CC", smiles=True) - # ethane2 = mb.load("CC", smiles=True) + def test_ordered_bonds(self): + ethane = mb.load("CC", smiles=True) + ethane2 = mb.load("CC", smiles=True) for bond2, bond in zip(ethane2.bonds(), ethane.bonds()): assert bond2[0].name == bond[0].name assert all(bond2[0].pos == bond[0].pos) From 22b50ee73f96fa4f9283bb34ef616c8b3db38f07 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 14 Aug 2025 15:14:58 +0100 Subject: [PATCH 100/123] Add develop branch to CI trigger --- .github/workflows/CI.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index 7055d04ba..94c5be433 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -4,9 +4,11 @@ on: push: branches: - "main" + - "develop" pull_request: branches: - "main" + - "develop" schedule: - cron: "0 0 * * *" @@ -93,7 +95,7 @@ jobs: - uses: actions/checkout@v4 name: Checkout Branch / Pull Request - - uses: Vampire/setup-wsl@v5 + - uses: Vampire/setup-wsl@v6 with: distribution: Ubuntu-24.04 wsl-shell-user: runner From 6eddebb3b2ed55d3fbce7c22d95c60a4a9df39f6 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 14 Aug 2025 15:50:57 +0100 Subject: [PATCH 101/123] Add more doc strings and code comments --- mbuild/path.py | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index fc5f82ca8..90ee6fcae 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -580,7 +580,21 @@ def generate(self): class Knot(Path): - """Generate a knot path.""" + """Generate a knot path. + + Parameters + ---------- + spacing : float (nm) + The spacing between sites along the path. + N : int + The number of total sites in the path. + m : int in [3, 4, 5] + The number of crossings in the knot. + 3 gives the trefoil knot, 4 gives the figure 8 knot and 5 gives the cinquefoil knot. + Only values of 3, 4 and 5 are currently supported. + bond_graph : networkx.graph + Sets the bond graph between sites. + """ def __init__(self, spacing, N, m, bond_graph=None): self.spacing = spacing @@ -592,16 +606,20 @@ def generate(self): # Prevents spacing between sites changing with curvature t_dense = np.linspace(0, 2 * np.pi, 5000) # Base (unscaled) curve - if self.m == 3: # Trefoil knot (3_1) + if self.m == 3: # Trefoil knot https://en.wikipedia.org/wiki/Trefoil_knot R, r = 1.0, 0.3 x = (R + r * np.cos(3 * t_dense)) * np.cos(2 * t_dense) y = (R + r * np.cos(3 * t_dense)) * np.sin(2 * t_dense) z = r * np.sin(3 * t_dense) - elif self.m == 4: # Figure-eight knot (4_1) + elif ( + self.m == 4 + ): # Figure-eight https://en.wikipedia.org/wiki/Figure-eight_knot_(mathematics) x = (2 + np.cos(2 * t_dense)) * np.cos(3 * t_dense) y = (2 + np.cos(2 * t_dense)) * np.sin(3 * t_dense) z = np.sin(4 * t_dense) - elif self.m == 5: # Cinquefoil knot (5_1), a (5,2) torus knot + elif ( + self.m == 5 + ): # Cinquefoil knot https://en.wikipedia.org/wiki/Cinquefoil_knot R, r = 1.0, 0.3 x = (R + r * np.cos(5 * t_dense)) * np.cos(2 * t_dense) y = (R + r * np.cos(5 * t_dense)) * np.sin(2 * t_dense) From 80bbc2560276a0c600a71d0cf9e9d224129bb274 Mon Sep 17 00:00:00 2001 From: CalCraven Date: Thu, 14 Aug 2025 11:45:24 -0500 Subject: [PATCH 102/123] Transition to logging instead of warnings module --- mbuild/__init__.py | 119 ++++++++++++++++++++++ mbuild/box.py | 6 +- mbuild/compound.py | 40 ++++---- mbuild/conversion.py | 34 ++++--- mbuild/coordinate_transform.py | 62 +---------- mbuild/formats/cassandramcf.py | 29 +++--- mbuild/formats/json_formats.py | 8 +- mbuild/formats/vasp.py | 6 +- mbuild/lattice.py | 8 +- mbuild/lib/recipes/monolayer.py | 10 +- mbuild/packing.py | 10 +- mbuild/port.py | 10 +- mbuild/tests/test_box.py | 7 +- mbuild/tests/test_cif.py | 12 ++- mbuild/tests/test_compound.py | 67 ++++++++---- mbuild/tests/test_coordinate_transform.py | 7 -- mbuild/tests/test_packing.py | 27 ++--- mbuild/tests/test_port.py | 18 ++-- mbuild/tests/test_utils.py | 14 +-- mbuild/tests/test_vasp.py | 7 +- mbuild/utils/conversion.py | 8 +- mbuild/utils/decorators.py | 18 ++-- mbuild/utils/io.py | 8 +- 23 files changed, 335 insertions(+), 200 deletions(-) diff --git a/mbuild/__init__.py b/mbuild/__init__.py index 044df7e69..654ed782c 100644 --- a/mbuild/__init__.py +++ b/mbuild/__init__.py @@ -2,6 +2,10 @@ # ruff: noqa: F403 """mBuild: a hierarchical, component based molecule builder.""" +import logging +import sys +from logging.handlers import RotatingFileHandler + from mbuild.box import Box from mbuild.coarse_graining import coarse_grain from mbuild.compound import * @@ -15,3 +19,118 @@ __version__ = "1.2.1" __date__ = "2025-01-23" + + +class DeduplicationFilter(logging.Filter): + """A logging filter that suppresses duplicate messages.""" + + def __init__(self): + super().__init__() + self.logged_messages = set() + + def filter(self, record): + log_entry = (record.name, record.levelno, record.msg) + if log_entry not in self.logged_messages: + self.logged_messages.add(log_entry) + return True + return False + + +class HeaderRotatingFileHandler(RotatingFileHandler): + def __init__( + self, + filename, + mode="w", + maxBytes=0, + backupCount=0, + encoding=None, + delay=False, + header="", + ): + self.header = header + super().__init__(filename, mode, maxBytes, backupCount, encoding, delay) + + def _open(self): + """ + Open the current base log file, with the header written. + """ + stream = super()._open() + if stream.tell() == 0 and self.header: # Only write header if file is empty + stream.write(self.header + "\n") + return stream + + +class mBuildLogger: + def __init__(self): + self.library_logger = logging.getLogger("mbuild") + self.library_logger.setLevel(logging.DEBUG) + + # Create handlers + self.console_handler = logging.StreamHandler(sys.stdout) + self.console_handler.setLevel(logging.WARNING) + + # Create a formatter + self.formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + + # Add formatter to handlers + self.console_handler.setFormatter(self.formatter) + + # Initialize and add the deduplication filter + self.dedup_filter = DeduplicationFilter() + self.console_handler.addFilter(self.dedup_filter) + + # Clear any previous handlers to avoid duplicates in Jupyter + self._clear_handlers() + + # Add handlers to the library logger + self.library_logger.addHandler(self.console_handler) + + def _clear_handlers(self): + handlers = self.library_logger.handlers[:] + for handler in handlers: + self.library_logger.removeHandler(handler) + + def debug_file(self, filename: str): + """Print logging Debug messages to file `filename`.""" + # Get the path to the Python interpreter + python_executable = sys.executable + + # Get the list of command-line arguments + command_arguments = sys.argv + + # Construct the full command + full_command = [python_executable] + command_arguments + header = f"Log details for mBuild {__version__} from running \n{full_command}" + self.file_handler = HeaderRotatingFileHandler( + filename, mode="a", maxBytes=10**6, backupCount=2, header=header + ) + self.file_handler.setLevel(logging.DEBUG) + + self.file_handler.addFilter(DeduplicationFilter()) # fresh duplication handler + self.file_handler.setFormatter(self.formatter) + self.library_logger.addHandler(self.file_handler) + + def print_level(self, level: str): + """Print sys.stdout screen based on the logging `level` passed.""" + levelDict = { + "notset": logging.NOTSET, + "debug": logging.DEBUG, + "info": logging.INFO, + "warning": logging.WARNING, + "error": logging.ERROR, + "critical": logging.CRITICAL, + } + logLevel = levelDict.get(level.lower()) + if logLevel: + self.console_handler.setLevel(logLevel) # sets stdout + else: + raise ValueError( + f"INCORRECT {level=}. Please set level of {levelDict.keys()}" + ) + + +# Example usage in __init__.py +mbuild_logger = mBuildLogger() +mbuild_logger.library_logger.setLevel(logging.INFO) diff --git a/mbuild/box.py b/mbuild/box.py index 87f980eab..6264e14a2 100644 --- a/mbuild/box.py +++ b/mbuild/box.py @@ -1,6 +1,6 @@ """mBuild box module.""" -from warnings import warn +import logging import numpy as np @@ -8,6 +8,8 @@ __all__ = ["Box"] +logger = logging.getLogger(__name__) + class Box(object): """A box representing the bounds of the system. @@ -350,7 +352,7 @@ def _normalize_box(vectors): f"3D region in space.\n Box vectors evaluated: {vectors}" ) if det < 0.0: - warn( + logger.warning( "Box vectors provided for a left-handed basis, these will be " "transformed into a right-handed basis automatically." ) diff --git a/mbuild/compound.py b/mbuild/compound.py index f5d2ff7d6..e634a579f 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -1,15 +1,13 @@ """Module for working with mBuild Compounds.""" -__all__ = ["clone", "Compound", "Particle"] - import itertools +import logging import os import tempfile from collections import OrderedDict from collections.abc import Iterable from copy import deepcopy from typing import Sequence -from warnings import warn import ele import networkx as nx @@ -28,6 +26,10 @@ from mbuild.utils.io import import_, run_from_ipython from mbuild.utils.jsutils import overwrite_nglview_default +__all__ = ["clone", "Compound", "Particle"] + +logger = logging.getLogger(__name__) + def clone(existing_compound, clone_of=None, root_container=None): """Clone Compound. @@ -499,7 +501,7 @@ def mass(self): else: particle_masses = [self._particle_mass(p) for p in self.particles()] if None in particle_masses: - warn( + logger.info( f"Some particle of {self} does not have mass." "They will not be accounted for during this calculation." ) @@ -543,8 +545,8 @@ def charge(self): return self._particle_charge(self) charges = [p._charge for p in self.particles()] if None in charges: - warn( - f"Some particle of {self} does not have a charge." + logger.info( + f"Some particle of {self} does not have a charge. " "They will not be accounted for during this calculation." ) filtered_charges = [charge for charge in charges if charge is not None] @@ -659,7 +661,7 @@ def add( f"to Compounds. You tried to add '{new_child}'." ) if self._mass is not None and not isinstance(new_child, Port): - warn( + logger.info( f"{self} has a pre-defined mass of {self._mass}, " "which will be reset to zero now that it contains children " "compounds." @@ -726,7 +728,7 @@ def add( else: if inherit_box: if new_child.box is None: - warn( + logger.info( "The Compound you are adding has no box but " "inherit_box=True. The box of the original " "Compound will remain unchanged." @@ -735,7 +737,7 @@ def add( self.box = new_child.box else: if new_child.box is not None: - warn( + logger.info( "The Compound you are adding has a box. " "The box of the parent compound will be used. Use " "inherit_box = True if you wish to replace the parent " @@ -747,7 +749,7 @@ def add( if ( np.array(self.box.lengths) < np.array(self.get_boundingbox().lengths) ).any(): - warn( + logger.warning( "After adding new Compound, Compound.box.lengths < " "Compound.boundingbox.lengths. There may be particles " "outside of the defined simulation box" @@ -801,7 +803,7 @@ def _check_if_empty(child): to_remove.append(child) _check_if_empty(child.parent) else: - warn(f"This will remove all particles in {self}") + logger.warning(f"This will remove all particles in {self}") return for particle in particles_to_remove: @@ -1211,12 +1213,14 @@ def remove_bond(self, particle_pair): if self.root.bond_graph is None or not self.root.bond_graph.has_edge( *particle_pair ): - warn("Bond between {} and {} doesn't exist!".format(*particle_pair)) + raise MBuildError( + "Bond between {} and {} doesn't exist!".format(*particle_pair) + ) return self.root.bond_graph.remove_edge(*particle_pair) bond_vector = particle_pair[0].pos - particle_pair[1].pos if np.allclose(bond_vector, np.zeros(3)): - warn( + logger.warning( "Particles {} and {} overlap! Ports will not be added.".format( *particle_pair ) @@ -1293,7 +1297,7 @@ def box(self, box): # Make sure the box is bigger than the bounding box if box is not None: if np.asarray((box.lengths < self.get_boundingbox().lengths)).any(): - warn( + logger.warning( "Compound.box.lengths < Compound.boundingbox.lengths. " "There may be particles outside of the defined " "simulation box." @@ -1620,7 +1624,9 @@ def min_periodic_distance(self, xyz0, xyz1): raise MBuildError(f'Cannot calculate minimum periodic distance. ' f'No Box set for {self}') """ - warn(f"No Box object set for {self}, using rectangular bounding box") + logger.warning( + f"No Box object set for {self}, using rectangular bounding box" + ) self.box = self.get_boundingbox() if np.allclose(self.box.angles, 90.0): d = np.where( @@ -2386,7 +2392,7 @@ def _energy_minimize_openmm( pass else: - warn( + logger.warning( f"OpenMM Force {type(force).__name__} is " "not currently supported in _energy_minimize_openmm. " "This Force will not be updated!" @@ -2681,7 +2687,7 @@ def _energy_minimize_openbabel( "'MMFF94s', 'UFF', 'GAFF', and 'Ghemical'." "" ) - warn( + logger.info( "Performing energy minimization using the Open Babel package. " "Please refer to the documentation to find the appropriate " f"citations for Open Babel and the {forcefield} force field" diff --git a/mbuild/conversion.py b/mbuild/conversion.py index 178129703..66d184d85 100644 --- a/mbuild/conversion.py +++ b/mbuild/conversion.py @@ -1,11 +1,11 @@ """Module for handling conversions in mBuild.""" +import logging import os import sys from collections import defaultdict from copy import deepcopy from pathlib import Path -from warnings import warn import gmso import numpy as np @@ -23,6 +23,8 @@ from mbuild.formats.json_formats import compound_from_json, compound_to_json from mbuild.utils.io import has_mdtraj, has_openbabel, import_ +logger = logging.getLogger(__name__) + def load( filename_or_object, @@ -180,10 +182,10 @@ def load_object( # TODO 1.0: What is the use case for this? if isinstance(obj, mb.Compound): if not compound: - warn("Given object is already an mb.Compound, doing nothing.") + logger.info("Given object is already an mb.Compound, doing nothing.") return obj else: - warn("Given object is an mb.Compound, adding to the host compound.") + logger.info("Given object is an mb.Compound, adding to the host compound.") compound.add(obj) return compound @@ -443,7 +445,7 @@ def load_file( # text file detected, assume contain smiles string elif extension == ".txt": - warn(".txt file detected, loading as a SMILES string") + logger.info(".txt file detected, loading as a SMILES string") # Fail-safe measure compound = load_pybel_smiles(filename, compound) @@ -461,7 +463,7 @@ def load_file( # Then parmed reader elif backend == "parmed": - warn( + logger.warning( "Using parmed reader. Bonds may be inferred from inter-particle " "distances and standard residue templates. Please check that the " "bonds in mb.Compound are accurate" @@ -573,7 +575,7 @@ def from_parmed( # Convert box information if structure.box is not None: - warn("All angles are assumed to be 90 degrees") + logger.info("All angles are assumed to be 90 degrees") compound.box = Box(structure.box[0:3] / 10) return compound @@ -730,7 +732,7 @@ def from_pybel( resindex_to_cmpd = {} if coords_only: - raise Warning( + logger.error( "coords_only=True is not yet implemented for conversion from pybel" ) @@ -747,7 +749,7 @@ def from_pybel( element = None if use_element: if element is None: - warn( + logger.info( "No element detected for atom at index " f"{atom.idx} with number {atom.atomicnum}, type {atom.type}" ) @@ -795,8 +797,8 @@ def from_pybel( ) compound.box = box else: - if not ignore_box_warn: - warn(f"No unitcell detected for pybel.Molecule {pybel_mol}") + if not ignore_box_warn.info: + logger.info(f"No unitcell detected for pybel.Molecule {pybel_mol}") return compound @@ -997,7 +999,9 @@ def save( raise IOError(f"{filename} exists; not overwriting") if compound.charge: if round(compound.charge, 4) != 0.0: - warn(f"System is not charge neutral. Total charge is {compound.charge}.") + logger.info( + f"System is not charge neutral. Total charge is {compound.charge}." + ) extension = os.path.splitext(filename)[-1] # Keep json stuff with internal mbuild method @@ -1782,7 +1786,9 @@ def to_smiles(compound, backend="pybel"): if backend == "pybel": mol = to_pybel(compound) - warn("The bond orders will be guessed using pybelOBMol.PerceviedBondOrders()") + logger.info( + "The bond orders will be guessed using pybelOBMol.PerceviedBondOrders()" + ) mol.OBMol.PerceiveBondOrders() smiles_string = mol.write("smi").replace("\t", " ").split(" ")[0] @@ -2011,7 +2017,7 @@ def _infer_element_from_compound(compound, guessed_elements): except ElementError: try: element = element_from_name(compound.name) - warn_msg = ( + logger.warning_msg = ( f"No element attribute associated with '{compound}'; " f"Guessing it is element '{element}'" ) @@ -2023,7 +2029,7 @@ def _infer_element_from_compound(compound, guessed_elements): "compound name. Setting atomic number to zero." ) if compound.name not in guessed_elements: - warn(warn_msg) + logger.warning(warn_msg) guessed_elements.add(compound.name) return element diff --git a/mbuild/coordinate_transform.py b/mbuild/coordinate_transform.py index b9130d590..971821dd3 100644 --- a/mbuild/coordinate_transform.py +++ b/mbuild/coordinate_transform.py @@ -1,20 +1,19 @@ """Coordinate transformation functions.""" -from warnings import simplefilter, warn +import logging import numpy as np from numpy.linalg import inv, norm, svd -simplefilter("always", DeprecationWarning) - __all__ = [ "force_overlap", "x_axis_transform", "y_axis_transform", "z_axis_transform", - "equivalence_transform", ] +logger = logging.getLogger(__name__) + def force_overlap( move_this, from_positions, to_positions, add_bond=True, reset_labels=False @@ -61,7 +60,7 @@ def force_overlap( if add_bond: if isinstance(from_positions, Port) and isinstance(to_positions, Port): if not from_positions.anchor or not to_positions.anchor: - warn("Attempting to form bond from port that has no anchor") + logger.warning("Attempting to form bond from port that has no anchor") else: from_positions.anchor.parent.add_bond( (from_positions.anchor, to_positions.anchor) @@ -337,59 +336,6 @@ def _create_equivalence_transform(equiv): return T -def equivalence_transform(compound, from_positions, to_positions, add_bond=True): - """Compute an affine transformation. - - Maps the from_positions to the respective to_positions, and applies this - transformation to the compound. - - Parameters - ---------- - compound : mb.Compound - The Compound to be transformed. - from_positions : np.ndarray, shape=(n, 3), dtype=float - Original positions. - to_positions : np.ndarray, shape=(n, 3), dtype=float - New positions. - """ - warn( - "The `equivalence_transform` function is being phased out in favor of" - " `force_overlap`.", - DeprecationWarning, - ) - from mbuild.port import Port - - T = None - if isinstance(from_positions, (list, tuple)) and isinstance( - to_positions, (list, tuple) - ): - equivalence_pairs = zip(from_positions, to_positions) - elif isinstance(from_positions, Port) and isinstance(to_positions, Port): - equivalence_pairs, T = _choose_correct_port(from_positions, to_positions) - from_positions.used = True - to_positions.used = True - else: - equivalence_pairs = [(from_positions, to_positions)] - - if not T: - T = _create_equivalence_transform(equivalence_pairs) - atom_positions = compound.xyz_with_ports - atom_positions = T.apply_to(atom_positions) - compound.xyz_with_ports = atom_positions - - if add_bond: - if isinstance(from_positions, Port) and isinstance(to_positions, Port): - if not from_positions.anchor or not to_positions.anchor: - warn("Attempting to form bond from port that has no anchor") - else: - from_positions.anchor.parent.add_bond( - (from_positions.anchor, to_positions.anchor) - ) - to_positions.anchor.parent.add_bond( - (from_positions.anchor, to_positions.anchor) - ) - - def _choose_correct_port(from_port, to_port): """Chooses the direction when using an equivalence transform on two Ports. diff --git a/mbuild/formats/cassandramcf.py b/mbuild/formats/cassandramcf.py index 526605e52..c187d6697 100644 --- a/mbuild/formats/cassandramcf.py +++ b/mbuild/formats/cassandramcf.py @@ -5,13 +5,14 @@ from __future__ import division -import warnings +import logging from math import sqrt import networkx as nx import parmed as pmd __all__ = ["write_mcf"] +logger = logging.getLogger(__name__) def write_mcf(structure, filename, angle_style, dihedral_style, lj14=None, coul14=None): @@ -102,7 +103,7 @@ def write_mcf(structure, filename, angle_style, dihedral_style, lj14=None, coul1 else: coul14 = 0.0 if len(structure.dihedrals) > 0 or len(structure.rb_torsions) > 0: - warnings.warn( + logger.info( "Unable to infer coulombic 1-4 scaling factor. Setting to " "{:.1f}".format(coul14) ) @@ -118,7 +119,7 @@ def write_mcf(structure, filename, angle_style, dihedral_style, lj14=None, coul1 ] if all([c_eps == 0 for c_eps in combined_eps_list]): lj14 = 0.0 - warnings.warn( + logger.info( "Unable to infer LJ 1-4 scaling factor. Setting to " "{:.1f}".format(lj14) ) @@ -130,7 +131,7 @@ def write_mcf(structure, filename, angle_style, dihedral_style, lj14=None, coul1 break else: lj14 = 0.0 - warnings.warn( + logger.info( "Unable to infer LJ 1-4 scaling factor. Setting to {:.1f}".format( lj14 ) @@ -138,7 +139,7 @@ def write_mcf(structure, filename, angle_style, dihedral_style, lj14=None, coul1 else: lj14 = 0.0 if len(structure.dihedrals) > 0 or len(structure.rb_torsions) > 0: - warnings.warn( + logger.info( "Unable to infer LJ 1-4 scaling factor. Setting to {:.1f}".format( lj14 ) @@ -197,7 +198,7 @@ def _id_rings_fragments(structure): ) if len(structure.bonds) == 0: - warnings.warn("No bonds found. Cassandra will interpet this as a rigid species") + logger.info("No bonds found. Cassandra will interpet this as a rigid species") in_ring = [False] * len(structure.atoms) frag_list = [] frag_conn = [] @@ -272,7 +273,7 @@ def _id_rings_fragments(structure): if len(shared_atoms) == 2: frag_conn.append([i, j]) elif len(shared_atoms) > 2: - warnings.warn( + logger.warning( "Fragments share more than two atoms... something may be " "going awry unless there are fused rings in your system. " "See below for details." @@ -313,14 +314,14 @@ def _write_atom_information(mcf_file, structure, in_ring, IG_CONSTANT_KCAL): n_unique_elements = len(set(elements)) for element in elements: if len(element) > max_element_length: - warnings.warn( + logger.info( "Element name {} will be shortened to {} characters. Please " "confirm your final MCF.".format(element, max_element_length) ) elements = [element[:max_element_length] for element in elements] if len(set(elements)) < n_unique_elements: - warnings.warn( + logger.info( "The number of unique elements has been reduced due to shortening " "the element name to {} characters.".format(max_element_length) ) @@ -328,7 +329,7 @@ def _write_atom_information(mcf_file, structure, in_ring, IG_CONSTANT_KCAL): n_unique_types = len(set(types)) for itype in types: if len(itype) > max_atomtype_length: - warnings.warn( + logger.info( "Type name {} will be shortened to {} characters as {}. Please " "confirm your final MCF.".format( itype, max_atomtype_length, itype[-max_atomtype_length:] @@ -336,7 +337,7 @@ def _write_atom_information(mcf_file, structure, in_ring, IG_CONSTANT_KCAL): ) types = [itype[-max_atomtype_length:] for itype in types] if len(set(types)) < n_unique_types: - warnings.warn( + logger.info( "The number of unique atomtypes has been reduced due to shortening " "the atomtype name to {} characters.".format(max_atomtype_length) ) @@ -518,9 +519,7 @@ def _write_dihedral_information(mcf_file, structure, dihedral_style, KCAL_TO_KJ) ] elif dihedral_style.casefold() == "none": - warnings.warn( - "Dihedral style 'none' selected. Ignoring dihedral parameters" - ) + logger.info("Dihedral style 'none' selected. Ignoring dihedral parameters") dihedral_style = dihedral_style.lower() if structure.dihedrals: dihedrals = structure.dihedrals @@ -623,7 +622,7 @@ def _write_fragment_information(mcf_file, structure, frag_list, frag_conn): mcf_file.write("1\n") mcf_file.write("1 2 1 2\n") else: - warnings.warn("More than two atoms present but no fragments identified.") + logger.info("More than two atoms present but no fragments identified.") mcf_file.write("0\n") else: mcf_file.write("{:d}\n".format(len(frag_list))) diff --git a/mbuild/formats/json_formats.py b/mbuild/formats/json_formats.py index be1a2da28..1804ec0b9 100644 --- a/mbuild/formats/json_formats.py +++ b/mbuild/formats/json_formats.py @@ -1,6 +1,7 @@ """JSON format.""" import json +import logging from collections import OrderedDict import ele @@ -9,6 +10,8 @@ from mbuild.bond_graph import BondGraph from mbuild.exceptions import MBuildError +logger = logging.getLogger(__name__) + def compound_from_json(json_file): """Convert the given json file into a Compound. @@ -264,7 +267,6 @@ def _add_bonds(compound_dict, parent, converted_dict): def _perform_sanity_check(json_dict): """Perform Sanity Check on the JSON File.""" - from warnings import warn warning_msg = "This Json was written using {0}, current mbuild version is {1}." this_version = mb.__version__ @@ -285,4 +287,6 @@ def _perform_sanity_check(json_dict): + " Cannot Convert JSON to compound" ) if minor != this_minor: - warn(warning_msg.format(json_mbuild_version, this_version) + " Will Proceed.") + logging.warning( + warning_msg.format(json_mbuild_version, this_version) + " Will Proceed." + ) diff --git a/mbuild/formats/vasp.py b/mbuild/formats/vasp.py index 507c54e4f..a49e939cd 100644 --- a/mbuild/formats/vasp.py +++ b/mbuild/formats/vasp.py @@ -1,6 +1,6 @@ """VASP POSCAR format.""" -import warnings +import logging from itertools import chain import numpy as np @@ -11,6 +11,8 @@ __all__ = ["write_poscar", "read_poscar"] +logger = logging.getLogger(__name__) + def write_poscar(compound, filename, lattice_constant=1.0, coord_style="cartesian"): """Write a VASP POSCAR file from a Compound. @@ -51,7 +53,7 @@ def write_poscar(compound, filename, lattice_constant=1.0, coord_style="cartesia except AttributeError: lattice = compound.get_boundingbox().vectors if coord_style == "direct": - warnings.warn( + logger.info( "'direct' coord_style specified, but compound has no box " "-- using 'cartesian' instead" ) diff --git a/mbuild/lattice.py b/mbuild/lattice.py index 893469bd1..3e9a9a644 100644 --- a/mbuild/lattice.py +++ b/mbuild/lattice.py @@ -1,8 +1,8 @@ """mBuild lattice module for working with crystalline systems.""" import itertools as it +import logging import pathlib -import warnings from collections import defaultdict import numpy as np @@ -14,6 +14,8 @@ __all__ = ["load_cif", "Lattice"] +logger = logging.getLogger(__name__) + def load_cif(file_or_path=None, wrap_coords=False): """Load a CifFile object into an mbuild.Lattice. @@ -654,9 +656,7 @@ def populate(self, compound_dict=None, x=1, y=1, z=1): ) # Raise warnings about assumed elements for element in elementsSet: - warnings.warn( - f"Element assumed from cif file to be {element}.", UserWarning - ) + logger.info(f"Element assumed from cif file to be {element}.") ret_lattice.add(compoundsList) # Create mbuild.box ret_lattice.box = mb.Box(lengths=[a * x, b * y, c * z], angles=self.angles) diff --git a/mbuild/lib/recipes/monolayer.py b/mbuild/lib/recipes/monolayer.py index 97dfa7d23..011e70e43 100644 --- a/mbuild/lib/recipes/monolayer.py +++ b/mbuild/lib/recipes/monolayer.py @@ -1,7 +1,7 @@ """mBuild monolayer recipe.""" +import logging from copy import deepcopy -from warnings import warn import numpy as np @@ -9,6 +9,8 @@ __all__ = ["Monolayer"] +logger = logging.getLogger(__name__) + class Monolayer(mb.Compound): """A general monolayer recipe. @@ -75,7 +77,7 @@ def __init__( # Create sub-pattern for this chain type subpattern = deepcopy(pattern) n_points = int(round(fraction * n_chains)) - warn("\n Adding {} of chain {}".format(n_points, chain)) + logger.info("\n Adding {} of chain {}".format(n_points, chain)) pick = np.random.choice( subpattern.points.shape[0], n_points, replace=False ) @@ -98,10 +100,10 @@ def __init__( self.add(attached_chains) else: - warn("\n No fractions provided. Assuming a single chain type.") + logger.info("\n No fractions provided. Assuming a single chain type.") # Attach final chain type. Remaining sites get a backfill. - warn("\n Adding {} of chain {}".format(len(pattern), chains[-1])) + logger.info("\n Adding {} of chain {}".format(len(pattern), chains[-1])) attached_chains, backfills = pattern.apply_to_compound( guest=chains[-1], host=self["tiled_surface"], backfill=backfill, **kwargs ) diff --git a/mbuild/packing.py b/mbuild/packing.py index 001f4fcbd..9940588e2 100644 --- a/mbuild/packing.py +++ b/mbuild/packing.py @@ -3,11 +3,11 @@ http://leandro.iqm.unicamp.br/m3g/packmol/home.shtml """ +import logging import os import shutil import sys import tempfile -import warnings from itertools import zip_longest from subprocess import PIPE, Popen @@ -20,6 +20,8 @@ __all__ = ["fill_box", "fill_region", "fill_sphere", "solvate"] +logger = logging.getLogger(__name__) + PACKMOL = shutil.which("packmol") PACKMOL_HEADER = """ tolerance {0:.16f} @@ -91,7 +93,7 @@ def check_packmol_args(custom_args): "See https://m3g.github.io/packmol/userguide.shtml#run" ) if key in default_args: - warnings.warn( + logger.info( f"The PACKMOL argument {key} was passed to `packmol_args`, " "but should be set using the corresponding function parameters. " "The value passed to the function will be used. " @@ -1062,7 +1064,7 @@ def _validate_mass(compound, n_compounds): "on how mass is handled." ) if found_zero_mass: - warnings.warn( + logger.info( "Some of the compounds or subcompounds in `compound` " "have a mass of zero/None. This may have an effect on " "density calculations" @@ -1199,7 +1201,7 @@ def _run_packmol(input_text, filled_xyz, temp_file, packmol_file): out, err = proc.communicate() if "WITHOUT PERFECT PACKING" in out: - warnings.warn( + logger.warning( "Packmol finished with imperfect packing. Using the .xyz_FORCED " "file instead. This may not be a sufficient packing result." ) diff --git a/mbuild/port.py b/mbuild/port.py index 65326967b..330510cd5 100644 --- a/mbuild/port.py +++ b/mbuild/port.py @@ -1,7 +1,7 @@ """Ports used to facilitate bond formation.""" import itertools -from warnings import warn +import logging import numpy as np @@ -9,6 +9,8 @@ from mbuild.compound import Compound, Particle from mbuild.coordinate_transform import angle, unit_vector +logger = logging.getLogger(__name__) + class Port(Compound): """A set of four ghost Particles used to connect parts. @@ -93,7 +95,7 @@ def update_separation(self, separation): shifted from the origin. """ if self.used: - warn( + logger.warning( "This port is already being used and changing its separation " "will have no effect on the distance between particles." ) @@ -112,7 +114,7 @@ def update_orientation(self, orientation): Vector along which to orient the port """ if self.used: - warn( + logger.warning( "This port is already being used and changing its orientation " "will have no effect on the direction between particles." ) @@ -147,7 +149,7 @@ def separation(self): if self.anchor: return np.linalg.norm(self.center - self.anchor.pos) else: - warn( + logger.warning( "This port is not anchored to another particle. Returning a " "separation of None" ) diff --git a/mbuild/tests/test_box.py b/mbuild/tests/test_box.py index 2f8b7b763..f0cc1cab0 100644 --- a/mbuild/tests/test_box.py +++ b/mbuild/tests/test_box.py @@ -1,3 +1,5 @@ +import logging + import numpy as np import pytest @@ -41,9 +43,10 @@ def test_from_lengths_angles(self, lengths, angles): ], ], ) - def test_left_handed_matrix(self, lh_matrix): - with pytest.warns(UserWarning, match=r"provided for a left\-handed basis"): + def test_left_handed_matrix(self, lh_matrix, caplog): + with caplog.at_level(logging.WARNING, logger="mbuild"): mb.Box.from_vectors(vectors=lh_matrix) + assert "provided for a left-handed basis" in caplog.text @pytest.mark.parametrize( "vecs", diff --git a/mbuild/tests/test_cif.py b/mbuild/tests/test_cif.py index ea36f4c7b..f402772e9 100644 --- a/mbuild/tests/test_cif.py +++ b/mbuild/tests/test_cif.py @@ -1,3 +1,4 @@ +import logging from collections import OrderedDict import numpy as np @@ -169,10 +170,11 @@ def test_cif_triclinic_box_properties(self): map(lambda x: x.element, periodic_boxed_molecule.particles()) ) - def test_cif_raise_warnings(self): - with pytest.warns( - UserWarning, - match=r"Element assumed from cif file to be Element: silicon, symbol: Si, atomic number: 14, mass: 28.085.", - ): + def test_cif_raise_warnings(self, caplog): + with caplog.at_level(logging.WARNING, logger="mbuild"): lattice_cif = load_cif(file_or_path=get_fn("ETV_triclinic.cif")) lattice_cif.populate(x=1, y=1, z=1) + assert ( + "Element assumed from cif file to be Element: silicon, symbol: Si, atomic number: 14, mass: 28.085." + in caplog.text + ) diff --git a/mbuild/tests/test_compound.py b/mbuild/tests/test_compound.py index 8438725f2..ad8e798b7 100644 --- a/mbuild/tests/test_compound.py +++ b/mbuild/tests/test_compound.py @@ -1,3 +1,4 @@ +import logging import os import sys @@ -651,21 +652,23 @@ def test_mass_add_port(self): A.add(mb.Port()) assert A.mass == 2.0 - def test_none_mass(self): + def test_none_mass(self, caplog): A = mb.Compound() assert A.mass is None container = mb.Compound(subcompounds=[A]) - with pytest.warns(UserWarning): + with caplog.at_level(logging.INFO, logger="mbuild"): container_mass = container.mass assert container_mass is None + assert "Some particle of Date: Sun, 17 Aug 2025 14:36:51 -0500 Subject: [PATCH 103/123] Update to f-strings --- mbuild/formats/cassandramcf.py | 15 +++++++-------- mbuild/lib/recipes/monolayer.py | 4 ++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/mbuild/formats/cassandramcf.py b/mbuild/formats/cassandramcf.py index c187d6697..1fbd3fee1 100644 --- a/mbuild/formats/cassandramcf.py +++ b/mbuild/formats/cassandramcf.py @@ -315,31 +315,30 @@ def _write_atom_information(mcf_file, structure, in_ring, IG_CONSTANT_KCAL): for element in elements: if len(element) > max_element_length: logger.info( - "Element name {} will be shortened to {} characters. Please " - "confirm your final MCF.".format(element, max_element_length) + f"Element name {element} will be shortened to {max_element_length} " + "characters. Please confirm your final MCF." ) elements = [element[:max_element_length] for element in elements] if len(set(elements)) < n_unique_elements: logger.info( "The number of unique elements has been reduced due to shortening " - "the element name to {} characters.".format(max_element_length) + f"the element name to {max_element_length} characters." ) n_unique_types = len(set(types)) for itype in types: if len(itype) > max_atomtype_length: logger.info( - "Type name {} will be shortened to {} characters as {}. Please " - "confirm your final MCF.".format( - itype, max_atomtype_length, itype[-max_atomtype_length:] - ) + f"Type name {itype} will be shortened to {max_atomtype_length} " + f"characters as {itype[-max_atomtype_length:]}. Please " + "confirm your final MCF." ) types = [itype[-max_atomtype_length:] for itype in types] if len(set(types)) < n_unique_types: logger.info( "The number of unique atomtypes has been reduced due to shortening " - "the atomtype name to {} characters.".format(max_atomtype_length) + f"the atomtype name to {max_atomtype_length} characters." ) vdw_type = "LJ" diff --git a/mbuild/lib/recipes/monolayer.py b/mbuild/lib/recipes/monolayer.py index 011e70e43..c2b20a2b7 100644 --- a/mbuild/lib/recipes/monolayer.py +++ b/mbuild/lib/recipes/monolayer.py @@ -77,7 +77,7 @@ def __init__( # Create sub-pattern for this chain type subpattern = deepcopy(pattern) n_points = int(round(fraction * n_chains)) - logger.info("\n Adding {} of chain {}".format(n_points, chain)) + logger.info(f"\n Adding {n_points} of chain {chain}") pick = np.random.choice( subpattern.points.shape[0], n_points, replace=False ) @@ -103,7 +103,7 @@ def __init__( logger.info("\n No fractions provided. Assuming a single chain type.") # Attach final chain type. Remaining sites get a backfill. - logger.info("\n Adding {} of chain {}".format(len(pattern), chains[-1])) + logger.info(f"\n Adding {len(pattern)} of chain {chains[-1]}") attached_chains, backfills = pattern.apply_to_compound( guest=chains[-1], host=self["tiled_surface"], backfill=backfill, **kwargs ) From e1f55dd47fe9b79e89ef7ff165b0950252d05cc1 Mon Sep 17 00:00:00 2001 From: CalCraven Date: Sun, 17 Aug 2025 14:44:50 -0500 Subject: [PATCH 104/123] Fix pybel warn bug --- mbuild/conversion.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mbuild/conversion.py b/mbuild/conversion.py index 66d184d85..2a90046fb 100644 --- a/mbuild/conversion.py +++ b/mbuild/conversion.py @@ -797,7 +797,7 @@ def from_pybel( ) compound.box = box else: - if not ignore_box_warn.info: + if not ignore_box_warn: logger.info(f"No unitcell detected for pybel.Molecule {pybel_mol}") return compound From e4265da5eeaa45eefbc210caec9bdf33fe1ad6af Mon Sep 17 00:00:00 2001 From: CalCraven Date: Sun, 17 Aug 2025 14:59:12 -0500 Subject: [PATCH 105/123] Fix caplog level for cif tests --- mbuild/tests/test_cif.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mbuild/tests/test_cif.py b/mbuild/tests/test_cif.py index f402772e9..5ba7471f6 100644 --- a/mbuild/tests/test_cif.py +++ b/mbuild/tests/test_cif.py @@ -171,7 +171,7 @@ def test_cif_triclinic_box_properties(self): ) def test_cif_raise_warnings(self, caplog): - with caplog.at_level(logging.WARNING, logger="mbuild"): + with caplog.at_level(logging.INFO, logger="mbuild"): lattice_cif = load_cif(file_or_path=get_fn("ETV_triclinic.cif")) lattice_cif.populate(x=1, y=1, z=1) assert ( From ea8bd27253341291747fa50a94f11dfbf57ed9bb Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 18 Aug 2025 12:38:21 +0100 Subject: [PATCH 106/123] Add dihedral and ignore params in gmso.apply --- mbuild/simulation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index ed1d21b7e..737385046 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -108,7 +108,7 @@ def _to_hoomd_snap_forces(self): # Convret to GMSO, apply forcefield top = self.compound.to_gmso() top.identify_connections() - apply(top, forcefields=self.forcefield) + apply(top, forcefields=self.forcefield, ignore_params=["dihedral", "improper"]) # Get hoomd snapshot and force objects forces, ref = gmso.external.to_hoomd_forcefield(top, r_cut=self.r_cut) snap, ref = gmso.external.to_gsd_snapshot(top) From bb5cda3015672173e2042077a8b9de0474ae1567 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 21:39:07 +0000 Subject: [PATCH 107/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.8 → v0.12.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.8...v0.12.9) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea4be9884..ea5a1f8de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.8 + rev: v0.12.9 hooks: # Run the linter. - id: ruff From 4ed51701f15271574fce0ae5fc54848f6ec6c12e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 Aug 2025 21:39:07 +0000 Subject: [PATCH 108/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.8 → v0.12.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.8...v0.12.9) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea4be9884..ea5a1f8de 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.8 + rev: v0.12.9 hooks: # Run the linter. - id: ruff From f6ad3c31ab751f64c5454751f3eebf0e7c604342 Mon Sep 17 00:00:00 2001 From: CalCraven Date: Tue, 19 Aug 2025 08:36:57 -0500 Subject: [PATCH 109/123] add ___init__.py to codecov ignore --- codecov.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/codecov.yml b/codecov.yml index ff3fe65c0..27ec07558 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,4 @@ ignore: - "mbuild/examples" - "mbuild/tests" + - "mbuild/__init__.py" From aae02f867ef66a7089c174309ccc3a5b61e08fa0 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 19 Aug 2025 15:28:06 +0100 Subject: [PATCH 110/123] Add 2 methods useful for picking next point when stringing together random walks --- mbuild/utils/density.py | 99 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 mbuild/utils/density.py diff --git a/mbuild/utils/density.py b/mbuild/utils/density.py new file mode 100644 index 000000000..9ad2c896f --- /dev/null +++ b/mbuild/utils/density.py @@ -0,0 +1,99 @@ +import numpy as np +from scipy.spatial import cKDTree + + +def rank_points_by_density(points, k=14, box_min=None, box_max=None, wall_cutoff=0.0): + """Rank points from lowest local density to highest. + + Notes + ----- + If box_min, box_max and wall_cutoff are given, then points within wall_cutoff + to the box boundaries are ignored. This ensure the effective "density" resulting + from boundaries is acounted for. + + This can be useful for stringing together mutliple random walks + with and without volume constraints. The use case for this function would be to find an existing site + from a random walk, or another path, that has relatively lower density than other sites in the path. + A new random walk path can begin from this site. + + If you want to find an unoccupied point with the lowest local density + use mbuild.utils.density.find_low_density_point instead. + + Parameters + ---------- + points : ndarray, shape (N, d) + Coordinates of points. + k : int, optional, default 14 + Number of neighbors to use for local density. + box_min : array-like, shape (d,) + Minimum boundary of box. Points closer than `wall_cutoff` will be ignored. + box_max : array-like, shape (d,) + Maximum boundary of box. + wall_cutoff : float (nm) + Distance from walls to ignore points. + + Returns + ------- + sorted_indices : ndarray + Indices of points (original array) sorted from lowest to highest local density. + """ + points = np.asarray(points) + N, dim = points.shape + # Check for points too close to boundaries (if given) + # Ignore these from density calculation, enforce high density due to proximity to wall + if box_min is not None and box_max is not None and wall_cutoff > 0.0: + box_min = np.asarray(box_min) + box_max = np.asarray(box_max) + mask = np.all( + (points > box_min + wall_cutoff) & (points < box_max - wall_cutoff), axis=1 + ) + valid_indices = np.nonzero(mask)[0] + filtered_points = points[mask] + else: + filtered_points = points + valid_indices = np.arange(N) + + tree = cKDTree(filtered_points) + dists, idxs = tree.query(filtered_points, k=k + 1) + avg_dist = np.mean(dists[:, 1:], axis=1) + local_density = 1 / (avg_dist + 1e-12) + # Sort indices by increasing density + sorted_order = np.argsort(local_density) + return valid_indices[sorted_order] + + +def find_low_density_point(points, box_min, box_max, edge_buffer=0, n_candidates=5000): + """Find an unoccupied point inside a box that is furthest from existing points. + + Parameters + ---------- + points : ndarray, shape (N, 3) + Array of existing points. + box_min : array-like, shape (d, 3) + Minimum coordinates of the box. + box_max : array-like, shape (d, 3) + Maximum coordinates of the box. + edge_buffer : float (nm) default 0.0 + The buffer to prevent selection of coordinates within + the buffer distance to the edge of the box. + n_candidates : int + Number of random candidate points to try. + These points will be used to chose the point with lowest local density. + Higher values may suffer performance costs, but give improved + sampling. + + Returns + ------- + best_point : ndarray, shape (d,3) + Coordinates of the lowest-density point. + """ + points = np.asarray(points) + dim = points.shape[1] + tree = cKDTree(points) + # Create random candidates inside the box to test and sample from + candidates = np.random.uniform( + box_min + edge_buffer, box_max - edge_buffer, size=(n_candidates, dim) + ) + dists, _ = tree.query(candidates, k=1) + idx = np.argmax(dists) + return candidates[idx] From f7e9a633e8ac5ad1ec8d439bd59377fda3efc308 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 20 Aug 2025 11:47:02 +0100 Subject: [PATCH 111/123] finding low density points returns all tested points, sorted from low to high density --- mbuild/utils/density.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mbuild/utils/density.py b/mbuild/utils/density.py index 9ad2c896f..b00e48100 100644 --- a/mbuild/utils/density.py +++ b/mbuild/utils/density.py @@ -95,5 +95,5 @@ def find_low_density_point(points, box_min, box_max, edge_buffer=0, n_candidates box_min + edge_buffer, box_max - edge_buffer, size=(n_candidates, dim) ) dists, _ = tree.query(candidates, k=1) - idx = np.argmax(dists) - return candidates[idx] + sorted_order = np.argsort(dists) + return candidates[sorted_order] From c8af3bdc4a16fcdc4daf66fec72c245a20bd720a Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 21 Aug 2025 14:56:41 +0100 Subject: [PATCH 112/123] Add a new random walk method that operates with compounds instead of hard spheres --- mbuild/path.py | 229 ++++++++++++++++++++++++++++++++++++++++++---- mbuild/polymer.py | 2 +- 2 files changed, 211 insertions(+), 20 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index 90ee6fcae..e2127d11b 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -8,6 +8,7 @@ from numba import njit from scipy.interpolate import interp1d +import mbuild as mb from mbuild import Compound from mbuild.utils.geometry import bounding_box @@ -102,6 +103,123 @@ def _path_history(self): pass +class CompoundRandomWalk: + def __init__( + self, + compound, + N, + bond_length, + min_angle=np.pi / 2, + max_angle=np.pi, + volume_constraint=None, + start_from_path=None, + start_from_path_index=None, + initial_point=None, + include_compound=None, + max_attempts=1e5, + trial_batch_size=10, + tolerance=0.15, + seed=42, + ): + self.compound = compound + self.N = N + self.bond_length = bond_length + self.min_angle = min_angle + self.max_angle = max_angle + self.volume_constraint = volume_constraint + self.start_from_path = start_from_path + self.start_from_path_index = start_from_path_index + if initial_point is not None: + self.initial_point = np.asarray(initial_point) + else: + self.initial_point = None + self.include_compound = include_compound + self.max_attempts = max_attempts + self.trial_batch_size = trial_batch_size + self.tolerance = tolerance + self.rng = np.random.default_rng(seed) + # Store everything here + self.parent_compound = Compound() + self.count = 0 + self.attempts = 0 + self.next_step = _batch_rotate_molecule + self.check_path = _check_new_molecule + self.generate() + + def generate(self): + last_compound = mb.clone(self.compound) + if self.initial_point is not None: + last_compound.translate(self.initial_point) + self.parent_compound.add(last_compound) + self.count += 1 + + while self.count < self.N: + this_compound = mb.clone(last_compound) + head_tail_vec = ( + this_compound["head"].anchor.xyz[0] + - this_compound["tail"].anchor.xyz[0] + ) + head_tail_norm = np.linalg.norm(head_tail_vec) + head_tail_unit = head_tail_vec / head_tail_norm + # TODO: Make sure bond length addition is handled correctly here + this_compound.translate( + by=head_tail_vec + head_tail_unit * self.bond_length + ) + # Find current torsion axis and hinge point used in trial moves + torsion_axis = head_tail_vec / np.linalg.norm(head_tail_vec) + hinge_point = ( + this_compound["head"].anchor.xyz[0] + + last_compound["tail"].anchor.xyz[0] + ) / 2 + # Get trial move batch values + bend_axes, bend_thetas, torsion_phis = self._generate_random_trials() + candiate_compound_coords = self.next_step( + coords=this_compound.xyz, + hinge_point=hinge_point, + torsion_axis=torsion_axis, + bend_axes=bend_axes, + bend_thetas=bend_thetas, + torsion_phis=torsion_phis, + ) + for coords in candiate_compound_coords: + if self.check_path( + system_coords=self.parent_compound.xyz, + new_coords=coords, + distance_tolerance=self.tolerance, + ): + this_compound.xyz = coords + self.parent_compound.add(this_compound) + self.parent_compound.add_bond( + [last_compound["head"].anchor, this_compound["tail"].anchor] + ) + last_compound = this_compound + self.count += 1 + break + self.attempts += 1 + + if self.attempts == self.max_attempts and self.count < self.N: + raise RuntimeError( + "The maximum number attempts allowed have passed, and only ", + f"{self.count} sucsessful attempts were completed.", + "Try changing the parameters or seed and running again.", + ) + + def _generate_random_trials(self): + """Generate a batch of random bend axes, bend angles, and torsion angles.""" + # Get a batch of bend angles + thetas = self.rng.uniform( + self.min_angle, self.max_angle, size=self.trial_batch_size + ).astype(np.float32) + # Batch of torsion rotation angles + phis = self.rng.uniform(0.0, np.pi, size=self.trial_batch_size).astype( + np.float32 + ) + # Batch of random vectors used for bend axis + vectors = self.rng.normal(size=(self.trial_batch_size, 3)).astype(np.float32) + vectors /= np.linalg.norm(vectors, axis=1)[:, None] + return vectors, thetas, phis + + class HardSphereRandomWalk(Path): def __init__( self, @@ -162,7 +280,7 @@ def __init__( ----- Each next-move can be attempted in batches, set by the ``trial_batch_size`` parameter. The batch size moves do not count towards the maximum allowed - attemps. For example, 1 random walk with a trail batch size of 20 counts at + attempts. For example, 1 random walk with a trail batch size of 20 counts at only one attempted move. Larger values of ``trial_batch_size`` may help highly constrained walks finish, but may hurt performance. @@ -190,6 +308,7 @@ def __init__( self.attempts = 0 self.start_from_path_index = start_from_path_index self.start_from_path = start_from_path + self._particle_pairs = {} # This random walk is including a previous path if start_from_path: @@ -210,8 +329,8 @@ def __init__( self._init_count = self.count # Select methods to use for random walk # Hard-coded for, possible to make other RW methods and pass them in - self.next_coordinate = _random_coordinate_numba - self.check_path = _check_path_numba + self.next_step = _random_coordinate + self.check_path = _check_path # Create RNG state. self.rng = np.random.default_rng(seed) super(HardSphereRandomWalk, self).__init__( @@ -274,7 +393,7 @@ def generate(self): # Choosing angles and vectors is the only random part # Get a batch of these once, pass them into numba functions batch_angles, batch_vectors = self._generate_random_trials() - new_xyzs = self.next_coordinate( + new_xyzs = self.next_step( pos1=self.coordinates[self.count], pos2=self.coordinates[self.count - 1], bond_length=self.bond_length, @@ -360,7 +479,7 @@ def _initial_points(self): started_next_path = False while not started_next_path: batch_angles, batch_vectors = self._generate_random_trials() - new_xyzs = self.next_coordinate( + new_xyzs = self.next_step( pos1=self.start_from_path.get_coordinates()[ self.start_from_path_index ], @@ -778,18 +897,9 @@ def generate(self): self.coordinates[i] = (0, x2d, y2d) -# Internal helper/utility methods below: -@njit(cache=True, fastmath=True) -def norm(vec): - """Used by HardSphereRandomWalk.""" - s = 0.0 - for i in range(vec.shape[0]): - s += vec[i] * vec[i] - return np.sqrt(s) - - +# METHODS BELOW ARE USED BY HardSphereRandomWalk @njit(cache=True, fastmath=True) -def _random_coordinate_numba( +def _random_coordinate( pos1, pos2, bond_length, @@ -797,7 +907,7 @@ def _random_coordinate_numba( r_vectors, batch_size, ): - """Default method for HardSphereRandomWalk.""" + """Default next_step method for HardSphereRandomWalk.""" v1 = pos2 - pos1 v1_norm = v1 / norm(v1) dot_products = (r_vectors * v1_norm).sum(axis=1) @@ -816,8 +926,8 @@ def _random_coordinate_numba( @njit(cache=True, fastmath=True) -def _check_path_numba(existing_points, new_point, radius, tolerance): - """Default method for HardSphereRandomWalk.""" +def _check_path(existing_points, new_point, radius, tolerance): + """Default check path method for HardSphereRandomWalk.""" min_sq_dist = (radius - tolerance) ** 2 for i in range(existing_points.shape[0]): dist_sq = 0.0 @@ -827,3 +937,84 @@ def _check_path_numba(existing_points, new_point, radius, tolerance): if dist_sq < min_sq_dist: return False return True + + +# METHODS BELOW ARE USED BY CompoundRandomWalk +@njit(cache=True, fastmath=True) +def _batch_rotate_molecule( + coords, hinge_point, torsion_axis, bend_axes, bend_thetas, torsion_phis +): + """Default next_step method for CompoundRandomWalk. + + Given a set of original molecular coordinates and a batch of bending axes, angles, and torsion angles + a batch of updated trial molecular coordinates is created. + """ + N_atoms = coords.shape[0] + N_trials = bend_axes.shape[0] + rotated_batch = np.zeros((N_trials, N_atoms, 3), dtype=np.float32) + torsion_axis = torsion_axis / norm(torsion_axis) + + for t in range(N_trials): + trial_coords = np.zeros((N_atoms, 3), dtype=np.float32) + for i in range(N_atoms): + trial_coords[i, :] = coords[i, :] + for i in range(N_atoms): + trial_coords[i, :] -= hinge_point + # Apply torsion rotation before any bending. + # Torsion done first as the torsion axis can change after bending. + phi = torsion_phis[t] + for i in range(N_atoms): + trial_coords[i, :] = rotate_vector(trial_coords[i, :], torsion_axis, phi) + # Apply bend rotation + bend_axis = bend_axes[t] / norm(bend_axes[t]) + theta = bend_thetas[t] + for i in range(N_atoms): + trial_coords[i, :] = rotate_vector(trial_coords[i, :], bend_axis, theta) + # Reverse the original hinge point translation + for i in range(N_atoms): + trial_coords[i, :] += hinge_point + rotated_batch[t, :, :] = trial_coords + return rotated_batch + + +@njit(cache=True, fastmath=True) +def _check_new_molecule(system_coords, new_coords, distance_tolerance): + """Default check_path method for CompoundRandomWalk.""" + tol2 = ( + distance_tolerance * distance_tolerance + ) # compare squared distances for speed + for i in range(new_coords.shape[0]): + for j in range(system_coords.shape[0]): + dx = new_coords[i, 0] - system_coords[j, 0] + dy = new_coords[i, 1] - system_coords[j, 1] + dz = new_coords[i, 2] - system_coords[j, 2] + if dx * dx + dy * dy + dz * dz < tol2: + return False + return True + + +# NUMBA HELPER/UTILITY METHODS: +@njit(cache=True, fastmath=True) +def norm(vec): + """Use in place of np.linalg.norm inside of numba functions.""" + s = 0.0 + for i in range(vec.shape[0]): + s += vec[i] * vec[i] + return np.sqrt(s) + + +@njit(cache=True, fastmath=True) +def rotate_vector(v, axis, theta): + """Rotate vector v around a normalized axis by angle theta using Rodrigues' formula.""" + c = np.cos(theta) + s = np.sin(theta) + k = axis + k_dot_v = k[0] * v[0] + k[1] * v[1] + k[2] * v[2] + cross = np.zeros(3) + cross[0] = k[1] * v[2] - k[2] * v[1] + cross[1] = k[2] * v[0] - k[0] * v[2] + cross[2] = k[0] * v[1] - k[1] * v[0] + rotated = np.zeros(3) + for i in range(3): + rotated[i] = v[i] * c + cross[i] * s + k[i] * k_dot_v * (1 - c) + return rotated diff --git a/mbuild/polymer.py b/mbuild/polymer.py index f9ed00a78..0ee12ef4b 100644 --- a/mbuild/polymer.py +++ b/mbuild/polymer.py @@ -193,7 +193,7 @@ def build_from_path( """ n = len(path.coordinates) - sum([1 for i in self.end_groups if i is not None]) self.build( - n=n, + n=n // len(sequence), sequence=sequence, add_hydrogens=add_hydrogens, bond_head_tail=bond_head_tail, From 3f5f49318c119898eca41bf824eadf1b1bfd1015 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 22 Aug 2025 15:41:37 +0100 Subject: [PATCH 113/123] Replace param name in volume classes --- mbuild/path.py | 30 +++++++++------ mbuild/utils/volumes.py | 83 +++++++++++++++++++++++++++++------------ 2 files changed, 78 insertions(+), 35 deletions(-) diff --git a/mbuild/path.py b/mbuild/path.py index e2127d11b..b111adb4c 100644 --- a/mbuild/path.py +++ b/mbuild/path.py @@ -111,6 +111,8 @@ def __init__( bond_length, min_angle=np.pi / 2, max_angle=np.pi, + min_torsion=0.0, + max_torsion=np.pi, volume_constraint=None, start_from_path=None, start_from_path_index=None, @@ -126,6 +128,8 @@ def __init__( self.bond_length = bond_length self.min_angle = min_angle self.max_angle = max_angle + self.min_torsion = min_torsion + self.max_torsion = max_torsion self.volume_constraint = volume_constraint self.start_from_path = start_from_path self.start_from_path_index = start_from_path_index @@ -156,8 +160,7 @@ def generate(self): while self.count < self.N: this_compound = mb.clone(last_compound) head_tail_vec = ( - this_compound["head"].anchor.xyz[0] - - this_compound["tail"].anchor.xyz[0] + this_compound[">"].anchor.xyz[0] - this_compound["<"].anchor.xyz[0] ) head_tail_norm = np.linalg.norm(head_tail_vec) head_tail_unit = head_tail_vec / head_tail_norm @@ -168,8 +171,7 @@ def generate(self): # Find current torsion axis and hinge point used in trial moves torsion_axis = head_tail_vec / np.linalg.norm(head_tail_vec) hinge_point = ( - this_compound["head"].anchor.xyz[0] - + last_compound["tail"].anchor.xyz[0] + this_compound[">"].anchor.xyz[0] + last_compound["<"].anchor.xyz[0] ) / 2 # Get trial move batch values bend_axes, bend_thetas, torsion_phis = self._generate_random_trials() @@ -181,6 +183,12 @@ def generate(self): bend_thetas=bend_thetas, torsion_phis=torsion_phis, ) + if self.volume_constraint: + is_inside_mask = self.volume_constraint.is_inside( + points=candiate_compound_coords, buffer=0.10 + ) + self.is_inside_mask = is_inside_mask + candiate_compound_coords = candiate_compound_coords[is_inside_mask] for coords in candiate_compound_coords: if self.check_path( system_coords=self.parent_compound.xyz, @@ -190,7 +198,7 @@ def generate(self): this_compound.xyz = coords self.parent_compound.add(this_compound) self.parent_compound.add_bond( - [last_compound["head"].anchor, this_compound["tail"].anchor] + [last_compound[">"].anchor, this_compound["<"].anchor] ) last_compound = this_compound self.count += 1 @@ -211,9 +219,9 @@ def _generate_random_trials(self): self.min_angle, self.max_angle, size=self.trial_batch_size ).astype(np.float32) # Batch of torsion rotation angles - phis = self.rng.uniform(0.0, np.pi, size=self.trial_batch_size).astype( - np.float32 - ) + phis = self.rng.uniform( + self.min_torsion, self.max_torsion, size=self.trial_batch_size + ).astype(np.float32) # Batch of random vectors used for bend axis vectors = self.rng.normal(size=(self.trial_batch_size, 3)).astype(np.float32) vectors /= np.linalg.norm(vectors, axis=1)[:, None] @@ -371,7 +379,7 @@ def generate(self): ] ) is_inside_mask = self.volume_constraint.is_inside( - points=np.array([xyz]), particle_radius=self.radius + points=np.array([xyz]), buffer=self.radius ) if np.all(is_inside_mask): self.coordinates[1] = self.coordinates[0] + xyz @@ -403,7 +411,7 @@ def generate(self): ) if self.volume_constraint: is_inside_mask = self.volume_constraint.is_inside( - points=new_xyzs, particle_radius=self.radius + points=new_xyzs, buffer=self.radius ) new_xyzs = new_xyzs[is_inside_mask] for xyz in new_xyzs: @@ -491,7 +499,7 @@ def _initial_points(self): ) if self.volume_constraint: is_inside_mask = self.volume_constraint.is_inside( - points=new_xyzs, particle_radius=self.radius + points=new_xyzs, buffer=self.radius ) new_xyzs = new_xyzs[is_inside_mask] for xyz in new_xyzs: diff --git a/mbuild/utils/volumes.py b/mbuild/utils/volumes.py index 4f8dbeec4..0dccb5b72 100644 --- a/mbuild/utils/volumes.py +++ b/mbuild/utils/volumes.py @@ -17,13 +17,10 @@ def __init__(self, Lx, Ly, Lz, center=(0, 0, 0)): self.mins = self.center - np.array([Lx / 2, Ly / 2, Lz / 2]) self.maxs = self.center + np.array([Lx / 2, Ly / 2, Lz / 2]) - def is_inside(self, points, particle_radius): - """Points and particle_radius are passed in from HardSphereRandomWalk""" + def is_inside(self, points, buffer): + """Points and buffer are passed in from HardSphereRandomWalk""" return is_inside_cuboid( - mins=self.mins, - maxs=self.maxs, - points=points, - particle_radius=particle_radius, + mins=self.mins, maxs=self.maxs, points=points, buffer=buffer ) @@ -34,11 +31,9 @@ def __init__(self, center, radius): self.mins = self.center - self.radius self.maxs = self.center + self.radius - def is_inside(self, points, particle_radius): - """Points and particle_radius are passed in from HardSphereRandomWalk""" - return is_inside_sphere( - points=points, sphere_radius=self.radius, particle_radius=particle_radius - ) + def is_inside(self, points, buffer): + """Points and buffer are passed in from HardSphereRandomWalk""" + return is_inside_sphere(points=points, sphere_radius=self.radius, buffer=buffer) class CylinderConstraint(Constraint): @@ -61,25 +56,25 @@ def __init__(self, center, radius, height): ] ) - def is_inside(self, points, particle_radius): - """Points and particle_radius are passed in from HardSphereRandomWalk""" + def is_inside(self, points, buffer): + """Points and buffer are passed in from HardSphereRandomWalk""" return is_inside_cylinder( points=points, center=self.center, cylinder_radius=self.radius, height=self.height, - particle_radius=particle_radius, + buffer=buffer, ) @njit(cache=True, fastmath=True) -def is_inside_cylinder(points, center, cylinder_radius, height, particle_radius): +def is_inside_cylinder(points, center, cylinder_radius, height, buffer): n_points = points.shape[0] results = np.empty(n_points, dtype=np.bool_) - max_r = cylinder_radius - particle_radius + max_r = cylinder_radius - buffer max_r_sq = max_r * max_r # Radial limit squared half_height = height / 2.0 - max_z = half_height - particle_radius + max_z = half_height - buffer # Shift to center for i in range(n_points): dx = points[i, 0] - center[0] @@ -93,10 +88,10 @@ def is_inside_cylinder(points, center, cylinder_radius, height, particle_radius) @njit(cache=True, fastmath=True) -def is_inside_sphere(sphere_radius, points, particle_radius): +def is_inside_sphere(sphere_radius, points, buffer): n_points = points.shape[0] results = np.empty(n_points, dtype=np.bool_) - max_distance = sphere_radius - particle_radius + max_distance = sphere_radius - buffer max_distance_sq = max_distance * max_distance for i in range(n_points): dist_from_center_sq = 0.0 @@ -107,16 +102,56 @@ def is_inside_sphere(sphere_radius, points, particle_radius): @njit(cache=True, fastmath=True) -def is_inside_cuboid(mins, maxs, points, particle_radius): +def is_inside_cuboid(mins, maxs, points, buffer): + """ + Works with: + points.shape == (N, 3) # N single-site particles + points.shape == (N, n, 3) # N molecules with n sites + Returns: + (N,) boolean mask: True if the particle/molecule is inside + """ + if points.ndim == 2: # (N, 3) + n_batches = points.shape[0] + results = np.empty(n_batches, dtype=np.bool_) + for b in range(n_batches): + inside = True + for j in range(3): + coord = points[b, j] + if coord - buffer < mins[j] or coord + buffer > maxs[j]: + inside = False + break + results[b] = inside + return results + + elif points.ndim == 3: # (N, n, 3) + n_batches = points.shape[0] + n_sites = points.shape[1] + results = np.empty(n_batches, dtype=np.bool_) + for b in range(n_batches): + inside = True + for i in range(n_sites): + for j in range(3): + coord = points[b, i, j] + if coord - buffer < mins[j] or coord + buffer > maxs[j]: + inside = False + break + if not inside: + break + results[b] = inside + return results + + else: + raise ValueError("points must have shape (N,3) or (N,n,3)") + + +@njit(cache=True, fastmath=True) +def _is_inside_cuboid(mins, maxs, points, buffer): n_points = points.shape[0] results = np.empty(n_points, dtype=np.bool_) for i in range(n_points): inside = True for j in range(3): - if ( - points[i, j] - particle_radius < mins[j] - or points[i, j] + particle_radius > maxs[j] - ): + if points[i, j] - buffer < mins[j] or points[i, j] + buffer > maxs[j]: inside = False break results[i] = inside From 45a6eed9611e52b99592c397acb0913b1f852dc0 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 25 Aug 2025 16:56:14 +0100 Subject: [PATCH 114/123] Reorg path stuff, build bond-graph as the HardSphereRandomWalk proceeds, fixes to CompoundRandomWalk (tbd if this stays) --- mbuild/compound.py | 11 +- mbuild/path/__init__.py | 3 + mbuild/{ => path}/path.py | 275 +++++++++++++++++--------------------- mbuild/path/path_utils.py | 127 ++++++++++++++++++ 4 files changed, 261 insertions(+), 155 deletions(-) create mode 100644 mbuild/path/__init__.py rename mbuild/{ => path}/path.py (85%) create mode 100644 mbuild/path/path_utils.py diff --git a/mbuild/compound.py b/mbuild/compound.py index f77b86ce6..9486d4907 100644 --- a/mbuild/compound.py +++ b/mbuild/compound.py @@ -196,7 +196,7 @@ def __init__( else: self._charge = charge self._mass = mass - + self._bond_tag = None self._hoomd_data = {} def particles(self, include_ports=False): @@ -259,6 +259,14 @@ def get_child_indices(self, child): ) return matching_indices + @property + def bond_tag(self): + return self._bond_tag + + @bond_tag.setter + def bond_tag(self, tag): + self._bond_tag = tag + def set_bond_graph(self, new_graph): """Manually set the compound's complete bond graph. @@ -1723,6 +1731,7 @@ def visualize( backend="py3dmol", color_scheme={}, bead_size=0.3, + show_bond_tags=False, ): # pragma: no cover """Visualize the Compound using py3dmol (default) or nglview. diff --git a/mbuild/path/__init__.py b/mbuild/path/__init__.py new file mode 100644 index 000000000..9da67e0b1 --- /dev/null +++ b/mbuild/path/__init__.py @@ -0,0 +1,3 @@ +# ruff: noqa: F401 +# ruff: noqa: F403 +from .path import CompoundRandomWalk, HardSphereRandomWalk, Lamellar, Path diff --git a/mbuild/path.py b/mbuild/path/path.py similarity index 85% rename from mbuild/path.py rename to mbuild/path/path.py index b111adb4c..87059f45e 100644 --- a/mbuild/path.py +++ b/mbuild/path/path.py @@ -2,14 +2,21 @@ import math from abc import abstractmethod +from copy import deepcopy import freud +import networkx as nx import numpy as np -from numba import njit from scipy.interpolate import interp1d import mbuild as mb from mbuild import Compound +from mbuild.path.path_utils import ( + batch_rotate_molecule, + check_new_molecule, + check_path, + random_coordinate, +) from mbuild.utils.geometry import bounding_box @@ -79,10 +86,9 @@ def neighbor_list(self, r_max, query_points, coordinates=None, box=None): def to_compound(self, bead_name="_A", bead_mass=1): """Visualize a path as an mBuild Compound.""" compound = Compound() - for xyz in self.coordinates: - compound.add(Compound(name=bead_name, mass=bead_mass, pos=xyz)) - if self.bond_graph: - compound.set_bond_graph(self.bond_graph) + for node_id, attrs in self.bond_graph.nodes(data=True): + compound.add(Compound(name=attrs["name"], pos=attrs["xyz"])) + compound.set_bond_graph(self.bond_graph) return compound def apply_mapping(self): @@ -122,6 +128,7 @@ def __init__( trial_batch_size=10, tolerance=0.15, seed=42, + print_status=False, ): self.compound = compound self.N = N @@ -146,18 +153,31 @@ def __init__( self.parent_compound = Compound() self.count = 0 self.attempts = 0 - self.next_step = _batch_rotate_molecule - self.check_path = _check_new_molecule + self.next_step = batch_rotate_molecule + self.check_path = check_new_molecule + self.print_status = print_status self.generate() def generate(self): last_compound = mb.clone(self.compound) if self.initial_point is not None: last_compound.translate(self.initial_point) + if self.include_compound: + if not self.check_path( + system_coords=self.include_compound.xyz, + new_coords=last_compound.xyz, + distance_tolerance=self.tolerance, + ): + raise RuntimeError( + "The starting point chosen causes overlapping particles\n" + "with the particles given in `include_compound`." + ) + self.parent_compound.add(last_compound) - self.count += 1 + # self.count += 1 - while self.count < self.N: + while self.count < self.N - 1: + step_success = False this_compound = mb.clone(last_compound) head_tail_vec = ( this_compound[">"].anchor.xyz[0] - this_compound["<"].anchor.xyz[0] @@ -170,11 +190,14 @@ def generate(self): ) # Find current torsion axis and hinge point used in trial moves torsion_axis = head_tail_vec / np.linalg.norm(head_tail_vec) + # Mid point between tail (<) of this step and head (>) of last hinge_point = ( - this_compound[">"].anchor.xyz[0] + last_compound["<"].anchor.xyz[0] + this_compound["<"].anchor.xyz[0] + last_compound[">"].anchor.xyz[0] ) / 2 # Get trial move batch values - bend_axes, bend_thetas, torsion_phis = self._generate_random_trials() + bend_axes, bend_thetas, torsion_phis = self._generate_random_trials( + torsion_axis=torsion_axis + ) candiate_compound_coords = self.next_step( coords=this_compound.xyz, hinge_point=hinge_point, @@ -190,8 +213,14 @@ def generate(self): self.is_inside_mask = is_inside_mask candiate_compound_coords = candiate_compound_coords[is_inside_mask] for coords in candiate_compound_coords: + if self.include_compound: + check_coordinates = np.concatenate( + [self.include_compound.xyz, self.parent_compound.xyz] + ) + else: + check_coordinates = self.parent_compound.xyz if self.check_path( - system_coords=self.parent_compound.xyz, + system_coords=check_coordinates, new_coords=coords, distance_tolerance=self.tolerance, ): @@ -202,30 +231,56 @@ def generate(self): ) last_compound = this_compound self.count += 1 + step_success = True break self.attempts += 1 + if step_success and self.print_status: + print( + f"Finished {self.count} attempts. Success rate is {np.round(self.count / self.attempts, 3)}" + ) if self.attempts == self.max_attempts and self.count < self.N: raise RuntimeError( - "The maximum number attempts allowed have passed, and only ", - f"{self.count} sucsessful attempts were completed.", - "Try changing the parameters or seed and running again.", + "The maximum number of attempts allowed have passed, and only " + f"{self.count} successful attempts were completed.\n" + "Try changing the parameters or seed and running again." ) - def _generate_random_trials(self): - """Generate a batch of random bend axes, bend angles, and torsion angles.""" - # Get a batch of bend angles - thetas = self.rng.uniform( + def _generate_random_trials(self, torsion_axis): + # Random bend angles + # These are the angles for bending at the hinge point between + # this step and the last step. 180 deg is no bend, 0 deg would result in + # this molecule bending back onto the last molecule + bend_thetas = np.pi - self.rng.uniform( self.min_angle, self.max_angle, size=self.trial_batch_size ).astype(np.float32) - # Batch of torsion rotation angles - phis = self.rng.uniform( + + # Random torsion angles + torsion_phis = self.rng.uniform( self.min_torsion, self.max_torsion, size=self.trial_batch_size ).astype(np.float32) - # Batch of random vectors used for bend axis - vectors = self.rng.normal(size=(self.trial_batch_size, 3)).astype(np.float32) - vectors /= np.linalg.norm(vectors, axis=1)[:, None] - return vectors, thetas, phis + + # Bend axes: cross torsion_axis with random vectors + # These need to be normal to the torsion axis in order to act as a hinge/bend + random_vecs = self.rng.normal(size=(self.trial_batch_size, 3)).astype( + np.float32 + ) + bend_axes = np.zeros_like(random_vecs) + for i in range(self.trial_batch_size): + v = random_vecs[i] + perp = np.cross(torsion_axis, v) + n = np.linalg.norm(perp) + if n < 1e-12: # handle parallel case + if abs(torsion_axis[0]) < 0.9: + perp = np.cross( + torsion_axis, np.array([1.0, 0.0, 0.0], dtype=np.float32) + ) + else: + perp = np.cross( + torsion_axis, np.array([0.0, 1.0, 0.0], dtype=np.float32) + ) + bend_axes[i] = perp # Do vector normalization in the numba method + return bend_axes.astype(np.float32), bend_thetas, torsion_phis class HardSphereRandomWalk(Path): @@ -236,16 +291,17 @@ def __init__( radius, min_angle, max_angle, + bead_name="_A", volume_constraint=None, start_from_path=None, start_from_path_index=None, + attach_paths=False, initial_point=None, include_compound=None, max_attempts=1e5, seed=42, trial_batch_size=20, tolerance=1e-5, - bond_graph=None, ): """Generates coordinates from a self avoiding random walk using fixed bond lengths, hard spheres, and minimum and maximum angles @@ -281,8 +337,6 @@ def __init__( for the random walk. tolerance : float, default = 1e-4 Tolerance used for rounding and checkig for overlaps. - bond_graph : networkx.graph.Graph; optional - Sets the bonding of sites along the path. Notes ----- @@ -309,6 +363,7 @@ def __init__( self.min_angle = min_angle self.max_angle = max_angle self.seed = seed + self.bead_name = bead_name self.volume_constraint = volume_constraint self.tolerance = tolerance self.trial_batch_size = int(trial_batch_size) @@ -316,6 +371,7 @@ def __init__( self.attempts = 0 self.start_from_path_index = start_from_path_index self.start_from_path = start_from_path + self.attach_paths = attach_paths self._particle_pairs = {} # This random walk is including a previous path @@ -329,7 +385,12 @@ def __init__( ) self.count = len(start_from_path.coordinates) - 1 N = None + if attach_paths: + bond_graph = deepcopy(start_from_path.bond_graph) + else: + bond_graph = nx.Graph() else: # Not starting from another path + bond_graph = nx.Graph() coordinates = np.zeros((N, 3), dtype=np.float32) self.count = 0 self.start_index = 0 @@ -337,8 +398,8 @@ def __init__( self._init_count = self.count # Select methods to use for random walk # Hard-coded for, possible to make other RW methods and pass them in - self.next_step = _random_coordinate - self.check_path = _check_path + self.next_step = random_coordinate + self.check_path = check_path # Create RNG state. self.rng = np.random.default_rng(seed) super(HardSphereRandomWalk, self).__init__( @@ -350,6 +411,11 @@ def generate(self): if not self.start_from_path and not self.volume_constraint: # Set the first coordinate self.coordinates[0] = initial_xyz + self.bond_graph.add_node( + self.count, + name=self.bead_name, + xyz=self.coordinates[self.count], + ) # If no volume constraint, then first move is always accepted phi = self.rng.uniform(0, 2 * np.pi) theta = self.rng.uniform(0, np.pi) @@ -362,6 +428,12 @@ def generate(self): ) self.coordinates[1] = self.coordinates[0] + next_pos self.count += 1 + self.bond_graph.add_node( + self.count, + name=self.bead_name, + xyz=self.coordinates[self.count], + ) + self.bond_graph.add_edge(u_of_edge=self.count - 1, v_of_edge=self.count) # Not starting from another path, but have a volume constraint # Possible for second point to be out-of-bounds @@ -384,16 +456,30 @@ def generate(self): if np.all(is_inside_mask): self.coordinates[1] = self.coordinates[0] + xyz self.count += 1 + self.bond_graph.add_node( + self.count, + name=self.bead_name, + xyz=self.coordinates[self.count], + ) + self.bond_graph.add_edge( + u_of_edge=self.count - 1, v_of_edge=self.count + ) self.attempts += 1 next_point_found = True # 2nd point failed, continue while loop self.attempts += 1 - # Starting random walk from a previous set of coordinates + # Starting random walk from a previous set of coordinates (another path) # This point was accepted in self._initial_point with these conditions + # If attach_paths, then add edge between first node of this path and node of last path else: self.coordinates[self.count + 1] = initial_xyz self.count += 1 + self.bond_graph.add_node(self.count, name=self.bead_name, xyz=initial_xyz) + if self.attach_paths: + self.bond_graph.add_edge( + self.start_from_path_index, v_of_edge=self.count + ) self.attempts += 1 # Initial conditions set (points 1 and 2), now start RW with min/max angles @@ -432,6 +518,10 @@ def generate(self): ): self.coordinates[self.count + 1] = xyz self.count += 1 + self.bond_graph.add_node(self.count, name=self.bead_name, xyz=xyz) + self.bond_graph.add_edge( + u_of_edge=self.count - 1, v_of_edge=self.count + ) break self.attempts += 1 @@ -903,126 +993,3 @@ def generate(self): self.coordinates[i] = (x2d, 0, y2d) elif self.plane == "yz": self.coordinates[i] = (0, x2d, y2d) - - -# METHODS BELOW ARE USED BY HardSphereRandomWalk -@njit(cache=True, fastmath=True) -def _random_coordinate( - pos1, - pos2, - bond_length, - thetas, - r_vectors, - batch_size, -): - """Default next_step method for HardSphereRandomWalk.""" - v1 = pos2 - pos1 - v1_norm = v1 / norm(v1) - dot_products = (r_vectors * v1_norm).sum(axis=1) - r_perp = r_vectors - dot_products[:, None] * v1_norm - norms = np.sqrt((r_perp * r_perp).sum(axis=1)) - # Handle rare cases where rprep vectors approach zero - norms = np.where(norms < 1e-6, 1.0, norms) - r_perp_norm = r_perp / norms[:, None] - # Batch of trial next-step vectors using angles and r_norms - cos_thetas = np.cos(thetas) - sin_thetas = np.sin(thetas) - v2s = cos_thetas[:, None] * v1_norm + sin_thetas[:, None] * r_perp_norm - # Batch of trial positions - next_positions = pos1 + v2s * bond_length - return next_positions - - -@njit(cache=True, fastmath=True) -def _check_path(existing_points, new_point, radius, tolerance): - """Default check path method for HardSphereRandomWalk.""" - min_sq_dist = (radius - tolerance) ** 2 - for i in range(existing_points.shape[0]): - dist_sq = 0.0 - for j in range(existing_points.shape[1]): - diff = existing_points[i, j] - new_point[j] - dist_sq += diff * diff - if dist_sq < min_sq_dist: - return False - return True - - -# METHODS BELOW ARE USED BY CompoundRandomWalk -@njit(cache=True, fastmath=True) -def _batch_rotate_molecule( - coords, hinge_point, torsion_axis, bend_axes, bend_thetas, torsion_phis -): - """Default next_step method for CompoundRandomWalk. - - Given a set of original molecular coordinates and a batch of bending axes, angles, and torsion angles - a batch of updated trial molecular coordinates is created. - """ - N_atoms = coords.shape[0] - N_trials = bend_axes.shape[0] - rotated_batch = np.zeros((N_trials, N_atoms, 3), dtype=np.float32) - torsion_axis = torsion_axis / norm(torsion_axis) - - for t in range(N_trials): - trial_coords = np.zeros((N_atoms, 3), dtype=np.float32) - for i in range(N_atoms): - trial_coords[i, :] = coords[i, :] - for i in range(N_atoms): - trial_coords[i, :] -= hinge_point - # Apply torsion rotation before any bending. - # Torsion done first as the torsion axis can change after bending. - phi = torsion_phis[t] - for i in range(N_atoms): - trial_coords[i, :] = rotate_vector(trial_coords[i, :], torsion_axis, phi) - # Apply bend rotation - bend_axis = bend_axes[t] / norm(bend_axes[t]) - theta = bend_thetas[t] - for i in range(N_atoms): - trial_coords[i, :] = rotate_vector(trial_coords[i, :], bend_axis, theta) - # Reverse the original hinge point translation - for i in range(N_atoms): - trial_coords[i, :] += hinge_point - rotated_batch[t, :, :] = trial_coords - return rotated_batch - - -@njit(cache=True, fastmath=True) -def _check_new_molecule(system_coords, new_coords, distance_tolerance): - """Default check_path method for CompoundRandomWalk.""" - tol2 = ( - distance_tolerance * distance_tolerance - ) # compare squared distances for speed - for i in range(new_coords.shape[0]): - for j in range(system_coords.shape[0]): - dx = new_coords[i, 0] - system_coords[j, 0] - dy = new_coords[i, 1] - system_coords[j, 1] - dz = new_coords[i, 2] - system_coords[j, 2] - if dx * dx + dy * dy + dz * dz < tol2: - return False - return True - - -# NUMBA HELPER/UTILITY METHODS: -@njit(cache=True, fastmath=True) -def norm(vec): - """Use in place of np.linalg.norm inside of numba functions.""" - s = 0.0 - for i in range(vec.shape[0]): - s += vec[i] * vec[i] - return np.sqrt(s) - - -@njit(cache=True, fastmath=True) -def rotate_vector(v, axis, theta): - """Rotate vector v around a normalized axis by angle theta using Rodrigues' formula.""" - c = np.cos(theta) - s = np.sin(theta) - k = axis - k_dot_v = k[0] * v[0] + k[1] * v[1] + k[2] * v[2] - cross = np.zeros(3) - cross[0] = k[1] * v[2] - k[2] * v[1] - cross[1] = k[2] * v[0] - k[0] * v[2] - cross[2] = k[0] * v[1] - k[1] * v[0] - rotated = np.zeros(3) - for i in range(3): - rotated[i] = v[i] * c + cross[i] * s + k[i] * k_dot_v * (1 - c) - return rotated diff --git a/mbuild/path/path_utils.py b/mbuild/path/path_utils.py new file mode 100644 index 000000000..c826da62c --- /dev/null +++ b/mbuild/path/path_utils.py @@ -0,0 +1,127 @@ +"""Utility functions (mostly numba) for mbuild path generation""" + +import numpy as np +from numba import njit + + +@njit(cache=True, fastmath=True) +def random_coordinate( + pos1, + pos2, + bond_length, + thetas, + r_vectors, + batch_size, +): + """Default next_step method for HardSphereRandomWalk.""" + v1 = pos2 - pos1 + v1_norm = v1 / norm(v1) + dot_products = (r_vectors * v1_norm).sum(axis=1) + r_perp = r_vectors - dot_products[:, None] * v1_norm + norms = np.sqrt((r_perp * r_perp).sum(axis=1)) + # Handle rare cases where rprep vectors approach zero + norms = np.where(norms < 1e-6, 1.0, norms) + r_perp_norm = r_perp / norms[:, None] + # Batch of trial next-step vectors using angles and r_norms + cos_thetas = np.cos(thetas) + sin_thetas = np.sin(thetas) + v2s = cos_thetas[:, None] * v1_norm + sin_thetas[:, None] * r_perp_norm + # Batch of trial positions + next_positions = pos1 + v2s * bond_length + return next_positions + + +@njit(cache=True, fastmath=True) +def check_path(existing_points, new_point, radius, tolerance): + """Default check path method for HardSphereRandomWalk.""" + min_sq_dist = (radius - tolerance) ** 2 + for i in range(existing_points.shape[0]): + dist_sq = 0.0 + for j in range(existing_points.shape[1]): + diff = existing_points[i, j] - new_point[j] + dist_sq += diff * diff + if dist_sq < min_sq_dist: + return False + return True + + +@njit(cache=True, fastmath=True) +def batch_rotate_molecule( + coords, hinge_point, torsion_axis, bend_axes, bend_thetas, torsion_phis +): + """Default next_step method for CompoundRandomWalk. + + Given a set of original molecular coordinates and a batch of bending axes, angles, and torsion angles + a batch of updated trial molecular coordinates is created. + """ + coords = coords.astype(np.float32) + N_atoms = coords.shape[0] + N_trials = bend_axes.shape[0] + rotated_batch = np.zeros((N_trials, N_atoms, 3), dtype=np.float32) + torsion_axis = torsion_axis / norm(torsion_axis) + + for t in range(N_trials): + trial_coords = np.zeros((N_atoms, 3), dtype=np.float32) + for i in range(N_atoms): + trial_coords[i, :] = coords[i, :] + for i in range(N_atoms): + trial_coords[i, :] -= hinge_point + # Apply torsion rotation before any bending. + # Torsion done first as the torsion axis can change after bending. + phi = torsion_phis[t] + for i in range(N_atoms): + trial_coords[i, :] = rotate_vector(trial_coords[i, :], torsion_axis, phi) + # Apply bend rotation, bend_axis already normalized + bend_axis = bend_axes[t] / norm(bend_axes[t]) + theta = bend_thetas[t] + for i in range(N_atoms): + # TODO: Do normalization here instead of batch generation? + trial_coords[i, :] = rotate_vector(trial_coords[i, :], bend_axis, theta) + # Reverse the original hinge point translation + for i in range(N_atoms): + trial_coords[i, :] += hinge_point + rotated_batch[t, :, :] = trial_coords + return rotated_batch + + +@njit(cache=True, fastmath=True) +def check_new_molecule(system_coords, new_coords, distance_tolerance): + """Check if new molecule overlaps with system or with itself.""" + tol2 = distance_tolerance * distance_tolerance + system_coords = system_coords.astype(np.float32) + new_coords = new_coords.astype(np.float32) + # Check for overlaps + for i in range(new_coords.shape[0]): + for j in range(system_coords.shape[0]): + dx = new_coords[i, 0] - system_coords[j, 0] + dy = new_coords[i, 1] - system_coords[j, 1] + dz = new_coords[i, 2] - system_coords[j, 2] + if dx * dx + dy * dy + dz * dz < tol2: + return False + return True + + +@njit(cache=True, fastmath=True) +def norm(vec): + """Use in place of np.linalg.norm inside of numba functions.""" + s = 0.0 + for i in range(vec.shape[0]): + s += vec[i] * vec[i] + return np.sqrt(s) + + +@njit(cache=True, fastmath=True) +def rotate_vector(v, axis, theta): + """Rotate vector v around a normalized axis by angle theta using Rodrigues' formula.""" + c = np.cos(theta) + s = np.sin(theta) + k = axis + k_dot_v = k[0] * v[0] + k[1] * v[1] + k[2] * v[2] + cross = np.zeros(3) + cross[0] = k[1] * v[2] - k[2] * v[1] + cross[1] = k[2] * v[0] - k[0] * v[2] + cross[2] = k[0] * v[1] - k[1] * v[0] + rotated = np.zeros(3) + for i in range(3): + rotated[i] = v[i] * c + cross[i] * s + k[i] * k_dot_v * (1 - c) + return rotated From ac7f11f009bb36f5208da6c846b57408eb04b0ee Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 25 Aug 2025 21:25:24 +0000 Subject: [PATCH 115/123] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.9 → v0.12.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.9...v0.12.10) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea5a1f8de..6d303b899 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.9 + rev: v0.12.10 hooks: # Run the linter. - id: ruff From de4acdb80708de7dd345523ea2e2f32ad415dd52 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 26 Aug 2025 10:04:54 +0100 Subject: [PATCH 116/123] Remove neighborlist method from path, it's not being used anywhere at the moment --- mbuild/path/path.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/mbuild/path/path.py b/mbuild/path/path.py index 87059f45e..53491a760 100644 --- a/mbuild/path/path.py +++ b/mbuild/path/path.py @@ -4,7 +4,6 @@ from abc import abstractmethod from copy import deepcopy -import freud import networkx as nx import numpy as np from scipy.interpolate import interp1d @@ -17,7 +16,6 @@ check_path, random_coordinate, ) -from mbuild.utils.geometry import bounding_box class Path: @@ -68,21 +66,6 @@ def generate(self): """ pass - def neighbor_list(self, r_max, query_points, coordinates=None, box=None): - """Use freud to create a neighbor list of a set of coordinates.""" - if coordinates is None: - coordinates = self.coordinates - if box is None: - box = bounding_box(coordinates) - freud_box = freud.box.Box(Lx=box[0], Ly=box[1], Lz=box[2]) - aq = freud.locality.AABBQuery(freud_box, coordinates) - aq_query = aq.query( - query_points=query_points, - query_args=dict(r_min=0.0, r_max=r_max, exclude_ii=True), - ) - nlist = aq_query.toNeighborList() - return nlist - def to_compound(self, bead_name="_A", bead_mass=1): """Visualize a path as an mBuild Compound.""" compound = Compound() From 999dde3907e9a7fd915e8a8f7fae9ac9bc27fe6f Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 26 Aug 2025 10:19:38 +0100 Subject: [PATCH 117/123] Remove CompoundRandomWalk for now, it's getting a bit messy --- mbuild/path/__init__.py | 2 +- mbuild/path/path.py | 182 +------------------------------------- mbuild/path/path_utils.py | 56 ------------ 3 files changed, 2 insertions(+), 238 deletions(-) diff --git a/mbuild/path/__init__.py b/mbuild/path/__init__.py index 9da67e0b1..06c691f56 100644 --- a/mbuild/path/__init__.py +++ b/mbuild/path/__init__.py @@ -1,3 +1,3 @@ # ruff: noqa: F401 # ruff: noqa: F403 -from .path import CompoundRandomWalk, HardSphereRandomWalk, Lamellar, Path +from .path import HardSphereRandomWalk, Lamellar, Path diff --git a/mbuild/path/path.py b/mbuild/path/path.py index 53491a760..de494949c 100644 --- a/mbuild/path/path.py +++ b/mbuild/path/path.py @@ -8,14 +8,8 @@ import numpy as np from scipy.interpolate import interp1d -import mbuild as mb from mbuild import Compound -from mbuild.path.path_utils import ( - batch_rotate_molecule, - check_new_molecule, - check_path, - random_coordinate, -) +from mbuild.path.path_utils import check_path, random_coordinate class Path: @@ -92,180 +86,6 @@ def _path_history(self): pass -class CompoundRandomWalk: - def __init__( - self, - compound, - N, - bond_length, - min_angle=np.pi / 2, - max_angle=np.pi, - min_torsion=0.0, - max_torsion=np.pi, - volume_constraint=None, - start_from_path=None, - start_from_path_index=None, - initial_point=None, - include_compound=None, - max_attempts=1e5, - trial_batch_size=10, - tolerance=0.15, - seed=42, - print_status=False, - ): - self.compound = compound - self.N = N - self.bond_length = bond_length - self.min_angle = min_angle - self.max_angle = max_angle - self.min_torsion = min_torsion - self.max_torsion = max_torsion - self.volume_constraint = volume_constraint - self.start_from_path = start_from_path - self.start_from_path_index = start_from_path_index - if initial_point is not None: - self.initial_point = np.asarray(initial_point) - else: - self.initial_point = None - self.include_compound = include_compound - self.max_attempts = max_attempts - self.trial_batch_size = trial_batch_size - self.tolerance = tolerance - self.rng = np.random.default_rng(seed) - # Store everything here - self.parent_compound = Compound() - self.count = 0 - self.attempts = 0 - self.next_step = batch_rotate_molecule - self.check_path = check_new_molecule - self.print_status = print_status - self.generate() - - def generate(self): - last_compound = mb.clone(self.compound) - if self.initial_point is not None: - last_compound.translate(self.initial_point) - if self.include_compound: - if not self.check_path( - system_coords=self.include_compound.xyz, - new_coords=last_compound.xyz, - distance_tolerance=self.tolerance, - ): - raise RuntimeError( - "The starting point chosen causes overlapping particles\n" - "with the particles given in `include_compound`." - ) - - self.parent_compound.add(last_compound) - # self.count += 1 - - while self.count < self.N - 1: - step_success = False - this_compound = mb.clone(last_compound) - head_tail_vec = ( - this_compound[">"].anchor.xyz[0] - this_compound["<"].anchor.xyz[0] - ) - head_tail_norm = np.linalg.norm(head_tail_vec) - head_tail_unit = head_tail_vec / head_tail_norm - # TODO: Make sure bond length addition is handled correctly here - this_compound.translate( - by=head_tail_vec + head_tail_unit * self.bond_length - ) - # Find current torsion axis and hinge point used in trial moves - torsion_axis = head_tail_vec / np.linalg.norm(head_tail_vec) - # Mid point between tail (<) of this step and head (>) of last - hinge_point = ( - this_compound["<"].anchor.xyz[0] + last_compound[">"].anchor.xyz[0] - ) / 2 - # Get trial move batch values - bend_axes, bend_thetas, torsion_phis = self._generate_random_trials( - torsion_axis=torsion_axis - ) - candiate_compound_coords = self.next_step( - coords=this_compound.xyz, - hinge_point=hinge_point, - torsion_axis=torsion_axis, - bend_axes=bend_axes, - bend_thetas=bend_thetas, - torsion_phis=torsion_phis, - ) - if self.volume_constraint: - is_inside_mask = self.volume_constraint.is_inside( - points=candiate_compound_coords, buffer=0.10 - ) - self.is_inside_mask = is_inside_mask - candiate_compound_coords = candiate_compound_coords[is_inside_mask] - for coords in candiate_compound_coords: - if self.include_compound: - check_coordinates = np.concatenate( - [self.include_compound.xyz, self.parent_compound.xyz] - ) - else: - check_coordinates = self.parent_compound.xyz - if self.check_path( - system_coords=check_coordinates, - new_coords=coords, - distance_tolerance=self.tolerance, - ): - this_compound.xyz = coords - self.parent_compound.add(this_compound) - self.parent_compound.add_bond( - [last_compound[">"].anchor, this_compound["<"].anchor] - ) - last_compound = this_compound - self.count += 1 - step_success = True - break - self.attempts += 1 - if step_success and self.print_status: - print( - f"Finished {self.count} attempts. Success rate is {np.round(self.count / self.attempts, 3)}" - ) - - if self.attempts == self.max_attempts and self.count < self.N: - raise RuntimeError( - "The maximum number of attempts allowed have passed, and only " - f"{self.count} successful attempts were completed.\n" - "Try changing the parameters or seed and running again." - ) - - def _generate_random_trials(self, torsion_axis): - # Random bend angles - # These are the angles for bending at the hinge point between - # this step and the last step. 180 deg is no bend, 0 deg would result in - # this molecule bending back onto the last molecule - bend_thetas = np.pi - self.rng.uniform( - self.min_angle, self.max_angle, size=self.trial_batch_size - ).astype(np.float32) - - # Random torsion angles - torsion_phis = self.rng.uniform( - self.min_torsion, self.max_torsion, size=self.trial_batch_size - ).astype(np.float32) - - # Bend axes: cross torsion_axis with random vectors - # These need to be normal to the torsion axis in order to act as a hinge/bend - random_vecs = self.rng.normal(size=(self.trial_batch_size, 3)).astype( - np.float32 - ) - bend_axes = np.zeros_like(random_vecs) - for i in range(self.trial_batch_size): - v = random_vecs[i] - perp = np.cross(torsion_axis, v) - n = np.linalg.norm(perp) - if n < 1e-12: # handle parallel case - if abs(torsion_axis[0]) < 0.9: - perp = np.cross( - torsion_axis, np.array([1.0, 0.0, 0.0], dtype=np.float32) - ) - else: - perp = np.cross( - torsion_axis, np.array([0.0, 1.0, 0.0], dtype=np.float32) - ) - bend_axes[i] = perp # Do vector normalization in the numba method - return bend_axes.astype(np.float32), bend_thetas, torsion_phis - - class HardSphereRandomWalk(Path): def __init__( self, diff --git a/mbuild/path/path_utils.py b/mbuild/path/path_utils.py index c826da62c..8056ce11c 100644 --- a/mbuild/path/path_utils.py +++ b/mbuild/path/path_utils.py @@ -45,62 +45,6 @@ def check_path(existing_points, new_point, radius, tolerance): return True -@njit(cache=True, fastmath=True) -def batch_rotate_molecule( - coords, hinge_point, torsion_axis, bend_axes, bend_thetas, torsion_phis -): - """Default next_step method for CompoundRandomWalk. - - Given a set of original molecular coordinates and a batch of bending axes, angles, and torsion angles - a batch of updated trial molecular coordinates is created. - """ - coords = coords.astype(np.float32) - N_atoms = coords.shape[0] - N_trials = bend_axes.shape[0] - rotated_batch = np.zeros((N_trials, N_atoms, 3), dtype=np.float32) - torsion_axis = torsion_axis / norm(torsion_axis) - - for t in range(N_trials): - trial_coords = np.zeros((N_atoms, 3), dtype=np.float32) - for i in range(N_atoms): - trial_coords[i, :] = coords[i, :] - for i in range(N_atoms): - trial_coords[i, :] -= hinge_point - # Apply torsion rotation before any bending. - # Torsion done first as the torsion axis can change after bending. - phi = torsion_phis[t] - for i in range(N_atoms): - trial_coords[i, :] = rotate_vector(trial_coords[i, :], torsion_axis, phi) - # Apply bend rotation, bend_axis already normalized - bend_axis = bend_axes[t] / norm(bend_axes[t]) - theta = bend_thetas[t] - for i in range(N_atoms): - # TODO: Do normalization here instead of batch generation? - trial_coords[i, :] = rotate_vector(trial_coords[i, :], bend_axis, theta) - # Reverse the original hinge point translation - for i in range(N_atoms): - trial_coords[i, :] += hinge_point - rotated_batch[t, :, :] = trial_coords - return rotated_batch - - -@njit(cache=True, fastmath=True) -def check_new_molecule(system_coords, new_coords, distance_tolerance): - """Check if new molecule overlaps with system or with itself.""" - tol2 = distance_tolerance * distance_tolerance - system_coords = system_coords.astype(np.float32) - new_coords = new_coords.astype(np.float32) - # Check for overlaps - for i in range(new_coords.shape[0]): - for j in range(system_coords.shape[0]): - dx = new_coords[i, 0] - system_coords[j, 0] - dy = new_coords[i, 1] - system_coords[j, 1] - dz = new_coords[i, 2] - system_coords[j, 2] - if dx * dx + dy * dy + dz * dz < tol2: - return False - return True - - @njit(cache=True, fastmath=True) def norm(vec): """Use in place of np.linalg.norm inside of numba functions.""" From d1c2af76ca7cc0879ccec662e2b074bed1bb9e10 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 26 Aug 2025 14:36:48 +0100 Subject: [PATCH 118/123] Always set bond graph when starting from another path, add method for setting graph edges as the path is built --- mbuild/path/path.py | 57 ++++++++++++++++++++++++++++----------------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/mbuild/path/path.py b/mbuild/path/path.py index de494949c..1b00a3837 100644 --- a/mbuild/path/path.py +++ b/mbuild/path/path.py @@ -46,6 +46,29 @@ def _extend_coordinates(self, N): self.coordinates = new_array self.N += N + def add_edge(self, u, v): + """Add an edge to the Path's bond graph. + This sets attributes for the edge which include: bond direction, bond length and bond type + u -> v corresponds to previous site and current site where the bond direction is calculated as v - u. + """ + bond_vec = self.coordinates[v] - self.coordinates[u] + bond_length = np.linalg.norm(bond_vec) + bond_vec /= bond_length + # Get node names from previous step to current step + u_name = self.bond_graph.nodes[u]["name"] + v_name = self.bond_graph.nodes[v]["name"] + self.bond_graph.add_edge( + u_of_edge=u, + v_of_edge=v, + direction=bond_vec.tolist(), + length=float(bond_length), + bond_type=(u_name, v_name), + ) + + def get_bonded_sites(self): + """Get all bonded pairs and their bond-vector orientations.""" + pass + def get_coordinates(self): if isinstance(self.coordinates, list): return np.array(self.coordinates) @@ -188,10 +211,7 @@ def __init__( ) self.count = len(start_from_path.coordinates) - 1 N = None - if attach_paths: - bond_graph = deepcopy(start_from_path.bond_graph) - else: - bond_graph = nx.Graph() + bond_graph = deepcopy(start_from_path.bond_graph) else: # Not starting from another path bond_graph = nx.Graph() coordinates = np.zeros((N, 3), dtype=np.float32) @@ -211,14 +231,14 @@ def __init__( def generate(self): initial_xyz = self._initial_points() + # Set the first coordinate + self.coordinates[0] = initial_xyz + self.bond_graph.add_node( + self.count, + name=self.bead_name, + xyz=self.coordinates[self.count], + ) if not self.start_from_path and not self.volume_constraint: - # Set the first coordinate - self.coordinates[0] = initial_xyz - self.bond_graph.add_node( - self.count, - name=self.bead_name, - xyz=self.coordinates[self.count], - ) # If no volume constraint, then first move is always accepted phi = self.rng.uniform(0, 2 * np.pi) theta = self.rng.uniform(0, np.pi) @@ -236,8 +256,7 @@ def generate(self): name=self.bead_name, xyz=self.coordinates[self.count], ) - self.bond_graph.add_edge(u_of_edge=self.count - 1, v_of_edge=self.count) - + self.add_edge(u=self.count - 1, v=self.count) # Not starting from another path, but have a volume constraint # Possible for second point to be out-of-bounds elif not self.start_from_path and self.volume_constraint: @@ -264,9 +283,7 @@ def generate(self): name=self.bead_name, xyz=self.coordinates[self.count], ) - self.bond_graph.add_edge( - u_of_edge=self.count - 1, v_of_edge=self.count - ) + self.add_edge(u=self.count - 1, v=self.count) self.attempts += 1 next_point_found = True # 2nd point failed, continue while loop @@ -280,9 +297,7 @@ def generate(self): self.count += 1 self.bond_graph.add_node(self.count, name=self.bead_name, xyz=initial_xyz) if self.attach_paths: - self.bond_graph.add_edge( - self.start_from_path_index, v_of_edge=self.count - ) + self.add_edge(u=self.start_from_path_index, v=self.count) self.attempts += 1 # Initial conditions set (points 1 and 2), now start RW with min/max angles @@ -322,9 +337,7 @@ def generate(self): self.coordinates[self.count + 1] = xyz self.count += 1 self.bond_graph.add_node(self.count, name=self.bead_name, xyz=xyz) - self.bond_graph.add_edge( - u_of_edge=self.count - 1, v_of_edge=self.count - ) + self.add_edge(u=self.count - 1, v=self.count) break self.attempts += 1 From 56692d497b4cc064d33126d4f768dbc9937d797f Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Tue, 26 Aug 2025 16:57:21 +0100 Subject: [PATCH 119/123] Fix some counting and indexing logic when starting from a path --- mbuild/path/path.py | 16 +++++++++------- mbuild/tests/test_path.py | 6 ++++-- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/mbuild/path/path.py b/mbuild/path/path.py index 1b00a3837..aca0a5ea3 100644 --- a/mbuild/path/path.py +++ b/mbuild/path/path.py @@ -209,9 +209,11 @@ def __init__( ), axis=0, ) - self.count = len(start_from_path.coordinates) - 1 + self.count = len(start_from_path.coordinates) N = None bond_graph = deepcopy(start_from_path.bond_graph) + if start_from_path_index < 0: + self.start_from_path_index = self.count + start_from_path_index else: # Not starting from another path bond_graph = nx.Graph() coordinates = np.zeros((N, 3), dtype=np.float32) @@ -232,7 +234,7 @@ def __init__( def generate(self): initial_xyz = self._initial_points() # Set the first coordinate - self.coordinates[0] = initial_xyz + self.coordinates[self.count] = initial_xyz self.bond_graph.add_node( self.count, name=self.bead_name, @@ -293,8 +295,6 @@ def generate(self): # This point was accepted in self._initial_point with these conditions # If attach_paths, then add edge between first node of this path and node of last path else: - self.coordinates[self.count + 1] = initial_xyz - self.count += 1 self.bond_graph.add_node(self.count, name=self.bead_name, xyz=initial_xyz) if self.attach_paths: self.add_edge(u=self.start_from_path_index, v=self.count) @@ -366,7 +366,7 @@ def _initial_points(self): if self.initial_point is not None: return self.initial_point - # Random initial point, bounds set by radius and N steps + # Random initial point, no volume constraint: Bounds set by radius and N steps elif not any( [self.volume_constraint, self.initial_point, self.start_from_path] ): @@ -386,9 +386,11 @@ def _initial_points(self): # Starting from another path, run Monte Carlo # Accepted next move is first point of this random walk elif self.start_from_path and self.start_from_path_index is not None: + # TODO: handle start_from_path index of negative values + # Set to the corresponding actual index value of the last path if self.start_from_path_index == 0: - pos2_coord = 1 - else: + pos2_coord = 1 # Use the second (1) point of the last path for angles + else: # use the site previous to start_from_path_index for angles pos2_coord = self.start_from_path_index - 1 started_next_path = False while not started_next_path: diff --git a/mbuild/tests/test_path.py b/mbuild/tests/test_path.py index 1d15ad1f2..71484557a 100644 --- a/mbuild/tests/test_path.py +++ b/mbuild/tests/test_path.py @@ -81,12 +81,14 @@ def test_from_path(self): min_angle=np.pi / 4, max_angle=np.pi, max_attempts=1e4, - seed=14, + seed=24, start_from_path=rw_path, start_from_path_index=-1, ) + assert len(rw_path.coordinates) == 20 assert len(rw_path2.coordinates) == 40 - assert np.array_equal(rw_path.coordinates, rw_path2.coordinates[:20]) + for coord1, coord2 in zip(rw_path.coordinates[:10], rw_path2.coordinates[:10]): + assert np.allclose(coord1, coord2, atol=1e-6) def test_walk_inside_cube(self): cube = CuboidConstraint(Lx=5, Ly=5, Lz=5) From 6b3ee2f03e0d7a371354eb47d7db1eaacc22f97b Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Wed, 27 Aug 2025 16:29:02 +0100 Subject: [PATCH 120/123] Add beginning of data structure for RW bias --- .github/workflows/codeql.yml | 4 +- mbuild/path/bias.py | 118 +++++++++++++++++++++++++++++++++++ mbuild/path/path.py | 12 +++- mbuild/path/path_utils.py | 13 ++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 mbuild/path/bias.py diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3dfe6e23e..dd545ca8a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,9 @@ name: "CodeQL" on: push: - branches: [ "main" ] + branches: [ "develop" ] pull_request: - branches: [ "main" ] + branches: [ "develop" ] schedule: - cron: "50 6 * * 0" diff --git a/mbuild/path/bias.py b/mbuild/path/bias.py new file mode 100644 index 000000000..b3d5578b6 --- /dev/null +++ b/mbuild/path/bias.py @@ -0,0 +1,118 @@ +"""Contains biases that can be included in mbuild.path.HardSphereRandomWalk.""" + +import numpy as np + +from mbuild.path.path_utils import target_sq_distances + + +class Bias: + def __init__(self, system_coordinates, new_coordinates): + self.system_coordinates = system_coordinates + self.new_coordinates = new_coordinates + + def __call__(self): + raise NotImplementedError + + +class TargetCoordinate(Bias): + """Bias next-moves so that ones moving closer to a final coordinate are more likely to be accepted.""" + + def __init__(self, target_coordinate, weight, system_coordinates, new_coordinates): + self.target_coordinate = target_coordinate + super(TargetCoordinate, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + sq_distances = target_sq_distances(self.target_coordinate, self.new_coordinates) + sort_idx = np.argsort(sq_distances) + return self.new_points[sort_idx] + + +class AvoidCoordinate(Bias): + """Bias next-moves so that ones moving further from a specific coordinate are more likely to be accepted.""" + + def __init__(self, avoid_coordinate, weight, system_coordinates, new_coordinates): + self.avoid_coordinate = avoid_coordinate + super(AvoidCoordinate, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + sq_distances = target_sq_distances(self.avoid_coordinate, self.new_coordinates) + # Sort in descending order, largest to smallest + sort_idx = np.argsort(sq_distances)[::-1] + return self.new_points[sort_idx] + + +class TargetType(Bias): + """Bias next-moves so that ones moving towards a specific site type are more likely to be accepted.""" + + def __init__(self, site_type, weight, system_coordinates, new_coordinates): + self.site_type = site_type + super(TargetType, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + pass + + +class AvoidType(Bias): + """Bias next-moves so that ones moving away from a specific site type are more likely to be accepted.""" + + def __init__(self, site_type, weight, system_coordinates, new_coordinates): + self.site_type = site_type + super(AvoidType, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + pass + + +class TargetEdge(Bias): + """Bias next-moves so that ones moving towards a surface are more likely to be accepted.""" + + def __init__(self, weight, system_coordinates, volume_constraint, new_coordinates): + self.volume_constraint = volume_constraint + super(TargetEdge, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + pass + + +class AvoidEdge(Bias): + """Bias next-moves so that ones away from a surface are more likely to be accepted.""" + + def __init__(self, weight, system_coordinates, volume_constraint, new_coordinates): + self.volume_constraint = volume_constraint + super(AvoidEdge, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + pass + + +class TargetDirection(Bias): + """Bias next-moves so that ones moving along a certain direction are more likely to be accepted.""" + + def __init__(self, direction, weight, system_coordinates, new_coordinates): + self.direction = direction + super(TargetDirection, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + pass + + +class AvoidDirection(Bias): + """Bias next-moves so that ones not moving along a certain direction are more likely to be accepted.""" + + def __init__(self, direction, weight, system_coordinates, new_coordinates): + self.direction = direction + super(AvoidDirection, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + pass + + +class TargetPath(Bias): + """Bias next-moves so that ones following a pre-defined path are more likely to be accepted.""" + + def __init__(self, target_path, weight, system_coordinates, new_coordinates): + self.target_path = target_path + super(TargetPath, self).__init__(system_coordinates, new_coordinates) + + def __call__(self): + pass diff --git a/mbuild/path/path.py b/mbuild/path/path.py index aca0a5ea3..5857874a0 100644 --- a/mbuild/path/path.py +++ b/mbuild/path/path.py @@ -83,8 +83,8 @@ def generate(self): """ pass - def to_compound(self, bead_name="_A", bead_mass=1): - """Visualize a path as an mBuild Compound.""" + def to_compound(self): + """Convert a path and its bond graph to an mBuild Compound.""" compound = Compound() for node_id, attrs in self.bond_graph.nodes(data=True): compound.add(Compound(name=attrs["name"], pos=attrs["xyz"])) @@ -96,6 +96,14 @@ def apply_mapping(self): """Mapping other compounds onto a Path's coordinates mapping = {"A": "c1ccccc1C=C", "B": "C=CC=C"} + + for bond in bond graph edges: + site u: add compound + rotate site u head-tail vec to align with bond direction + set orientation and separation for site u head port + rotate site v tail-head vec to align with bond direction + set orientation and separation for site v tail port + """ pass diff --git a/mbuild/path/path_utils.py b/mbuild/path/path_utils.py index 8056ce11c..831c7d237 100644 --- a/mbuild/path/path_utils.py +++ b/mbuild/path/path_utils.py @@ -45,6 +45,19 @@ def check_path(existing_points, new_point, radius, tolerance): return True +@njit(cache=True, fastmath=True) +def target_sq_distances(target_coordinate, new_points): + """Return squared distances from target_coordinate to new_points.""" + n_points = new_points.shape[0] + sq_distances = np.empty(n_points, dtype=np.float32) + for i in range(n_points): + dx = target_coordinate[0] - new_points[i, 0] + dy = target_coordinate[1] - new_points[i, 1] + dz = target_coordinate[2] - new_points[i, 2] + sq_distances[i] = dx * dx + dy * dy + dz * dz + return sq_distances + + @njit(cache=True, fastmath=True) def norm(vec): """Use in place of np.linalg.norm inside of numba functions.""" From a6b6e0f52f3d21ef1754dcdc52968651f57ad5a6 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Thu, 28 Aug 2025 14:39:03 +0100 Subject: [PATCH 121/123] bug fix, some progress on coordinate bias --- mbuild/path/bias.py | 13 ++++++++++-- mbuild/path/path.py | 2 +- mbuild/utils/density.py | 2 +- mbuild/utils/volumes.py | 46 +---------------------------------------- 4 files changed, 14 insertions(+), 49 deletions(-) diff --git a/mbuild/path/bias.py b/mbuild/path/bias.py index b3d5578b6..20697e5fb 100644 --- a/mbuild/path/bias.py +++ b/mbuild/path/bias.py @@ -6,8 +6,17 @@ class Bias: - def __init__(self, system_coordinates, new_coordinates): - self.system_coordinates = system_coordinates + def __init__(self, random_walk, new_coordinates): + # Extract any needed information for all sub-classes + # Complete system coords needed for density and direction biases + self.system_coordinates = random_walk.coordinates + self.N = len(self.system_coordinates) + # Site types needed for TargetType and AvoidType + self.site_types = [ + attrs["name"] for node, attrs in random_walk.bond_graph.nodes(data=True) + ] + # Current step count needed for TargetPath + self.count = random_walk.count self.new_coordinates = new_coordinates def __call__(self): diff --git a/mbuild/path/path.py b/mbuild/path/path.py index 5857874a0..de173cb41 100644 --- a/mbuild/path/path.py +++ b/mbuild/path/path.py @@ -220,7 +220,7 @@ def __init__( self.count = len(start_from_path.coordinates) N = None bond_graph = deepcopy(start_from_path.bond_graph) - if start_from_path_index < 0: + if start_from_path_index is not None and start_from_path_index < 0: self.start_from_path_index = self.count + start_from_path_index else: # Not starting from another path bond_graph = nx.Graph() diff --git a/mbuild/utils/density.py b/mbuild/utils/density.py index b00e48100..b57576b1a 100644 --- a/mbuild/utils/density.py +++ b/mbuild/utils/density.py @@ -17,7 +17,7 @@ def rank_points_by_density(points, k=14, box_min=None, box_max=None, wall_cutoff A new random walk path can begin from this site. If you want to find an unoccupied point with the lowest local density - use mbuild.utils.density.find_low_density_point instead. + use `mbuild.utils.density.find_low_density_point` instead. Parameters ---------- diff --git a/mbuild/utils/volumes.py b/mbuild/utils/volumes.py index 0dccb5b72..e608b649b 100644 --- a/mbuild/utils/volumes.py +++ b/mbuild/utils/volumes.py @@ -72,10 +72,9 @@ def is_inside_cylinder(points, center, cylinder_radius, height, buffer): n_points = points.shape[0] results = np.empty(n_points, dtype=np.bool_) max_r = cylinder_radius - buffer - max_r_sq = max_r * max_r # Radial limit squared + max_r_sq = max_r * max_r half_height = height / 2.0 max_z = half_height - buffer - # Shift to center for i in range(n_points): dx = points[i, 0] - center[0] dy = points[i, 1] - center[1] @@ -103,49 +102,6 @@ def is_inside_sphere(sphere_radius, points, buffer): @njit(cache=True, fastmath=True) def is_inside_cuboid(mins, maxs, points, buffer): - """ - Works with: - points.shape == (N, 3) # N single-site particles - points.shape == (N, n, 3) # N molecules with n sites - Returns: - (N,) boolean mask: True if the particle/molecule is inside - """ - if points.ndim == 2: # (N, 3) - n_batches = points.shape[0] - results = np.empty(n_batches, dtype=np.bool_) - for b in range(n_batches): - inside = True - for j in range(3): - coord = points[b, j] - if coord - buffer < mins[j] or coord + buffer > maxs[j]: - inside = False - break - results[b] = inside - return results - - elif points.ndim == 3: # (N, n, 3) - n_batches = points.shape[0] - n_sites = points.shape[1] - results = np.empty(n_batches, dtype=np.bool_) - for b in range(n_batches): - inside = True - for i in range(n_sites): - for j in range(3): - coord = points[b, i, j] - if coord - buffer < mins[j] or coord + buffer > maxs[j]: - inside = False - break - if not inside: - break - results[b] = inside - return results - - else: - raise ValueError("points must have shape (N,3) or (N,n,3)") - - -@njit(cache=True, fastmath=True) -def _is_inside_cuboid(mins, maxs, points, buffer): n_points = points.shape[0] results = np.empty(n_points, dtype=np.bool_) for i in range(n_points): From 67544ef42140e926f8f123161953177bb09e57d4 Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Fri, 29 Aug 2025 16:21:47 +0100 Subject: [PATCH 122/123] CodeQL Fixes --- mbuild/path/path.py | 8 ++++++-- mbuild/simulation.py | 2 +- mbuild/utils/geometry.py | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/mbuild/path/path.py b/mbuild/path/path.py index de173cb41..2490267f0 100644 --- a/mbuild/path/path.py +++ b/mbuild/path/path.py @@ -28,10 +28,13 @@ def __init__(self, N=None, coordinates=None, bond_graph=None): # Neither is defined, use list for coordinates # Use case: Lamellar - Don't know N initially elif N is None and coordinates is None: - self.N = N self.coordinates = [] else: raise ValueError("Specify either one of N and coordinates, or neither.") + self.__post_init__() + + def __post_init__(self): + """Needed for CodeQL in order to call abstract method inside __init__()""" self.generate() if self.N is None: self.N = len(self.coordinates) @@ -225,6 +228,7 @@ def __init__( else: # Not starting from another path bond_graph = nx.Graph() coordinates = np.zeros((N, 3), dtype=np.float32) + N = None self.count = 0 self.start_index = 0 # Need this for error message about reaching max tries @@ -236,7 +240,7 @@ def __init__( # Create RNG state. self.rng = np.random.default_rng(seed) super(HardSphereRandomWalk, self).__init__( - coordinates=coordinates, N=None, bond_graph=bond_graph + coordinates=coordinates, N=N, bond_graph=bond_graph ) def generate(self): diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 737385046..72edb1703 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -87,7 +87,6 @@ def __init__( if forcefield == last_forcefield: snapshot = last_snapshot self.forces = last_forces - forcefield = last_forcefield else: # New foyer/gmso forcefield has been passed, reapply snapshot, self.forces = self._to_hoomd_snap_forces() compound._add_sim_data( @@ -143,6 +142,7 @@ def get_force(self, instance): for force in set(self.forces + self.active_forces + self.inactive_forces): if isinstance(force, instance): return force + raise ValueError(f"No force of {instance} was found.") def get_dpd_from_lj(self, A): """Make a best-guess DPD force from types and parameters of an LJ force.""" diff --git a/mbuild/utils/geometry.py b/mbuild/utils/geometry.py index ee037dfee..157c48227 100644 --- a/mbuild/utils/geometry.py +++ b/mbuild/utils/geometry.py @@ -2,7 +2,7 @@ import numpy as np -import mbuild as mb +from mbuild.box import Box from mbuild.coordinate_transform import angle @@ -124,7 +124,7 @@ def wrap_coords(xyz, box, mins=None): ----- Currently only supports orthorhombic boxes """ - if not isinstance(box, mb.Box): + if not isinstance(box, Box): box_arr = np.asarray(box) assert box_arr.shape == (3,) From c9ad217676d9aad916824dd0708828c7e0874d6c Mon Sep 17 00:00:00 2001 From: chrisjonesBSU Date: Mon, 20 Oct 2025 10:58:38 +0100 Subject: [PATCH 123/123] auto set box --- mbuild/simulation.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/mbuild/simulation.py b/mbuild/simulation.py index 72edb1703..585c3ecd8 100644 --- a/mbuild/simulation.py +++ b/mbuild/simulation.py @@ -104,6 +104,9 @@ def __init__( self.create_state_from_snapshot(snapshot) def _to_hoomd_snap_forces(self): + # If a box isn't set, make one with the buffer + if not self.compound.box: + self.compound.box = self.compound.get_boundingbox(pad_box=self.box_buffer) # Convret to GMSO, apply forcefield top = self.compound.to_gmso() top.identify_connections()