Skip to content

Commit bbf058b

Browse files
author
pmblanco
committed
Merge PR pyMBE-dev#148 (nanoparticles)
2 parents efce8e4 + b2cb022 commit bbf058b

32 files changed

Lines changed: 1983 additions & 138 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- Utility functions to load and save the new database via the pyMBE API, `pmb.save_database` and `pmb.load_database`. (#147)
1515
- Added functions to define particle states: `pmb.define_particle_states` and `pmb.define_monoprototic_particle_states`. (#147)
1616
- Added utility functions in `lib/handy_functions` to define residue and particle templates for aminoacids en peptides and residues: `define_protein_AA_particles`, `define_protein_AA_residues` and `define_peptide_AA_residues`. (#147)
17+
- Added support for nanoparticle templates and instances in the canonical storage layer via `NanoparticleTemplate` and `NanoparticleInstance`. (#148)
18+
- Added new API methods `pmb.define_nanoparticle` and `pmb.create_nanoparticle` to define and build nanoparticles with configurable core particles and surface site composition. (#148)
19+
- Added nanoparticle site-construction utilities in `pyMBE.lib.nanoparticle_tools` for spherical site distribution, patch construction, and overlap checks. (#148)
20+
- Added sample script `samples/nanoparticles_grxmc.py` to demonstrate nanoparticle setup and simulation workflows. (#148)
21+
- Added dedicated nanoparticle unit tests (`testsuite/nanoparticle_unit_tests.py`) and coverage for nanoparticle-related code paths. (#148)
1722

1823
## Changed
1924
- Create methods (`create_particle`, `create_residue`, `create_molecule`, `create_protein`, `create_hydrogel`) now raise a ValueError if no template is found for an input `name` instead than a warning. (#147)
@@ -22,10 +27,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2227
- Pka values are now stored as part of chemical reactions and no longer an attribute of particle templates. (#147)
2328
- Amino acid residue templates are no longer defined internally in `define_peptide` and `define_protein`. Those definitions are now exposed to the user. (#147)
2429
- Molecule templates now need to be defined to be used as templates for hydrogel chains in hydrogels. (#147)
30+
- Rigid-body setup is now integrated into nanoparticle creation, allowing nanoparticles to be initialized as rigid objects directly from `pmb.create_nanoparticle`. (#148)
31+
- Nanoparticle construction now supports primary/secondary site partitioning and multi-patch layouts driven by template parameters. (#148)
2532

2633
## Fixed
2734
- Wrong handling of units in `get_radius_map` when the `dimensionless` argument was triggered. (#147)
2835
- Utility methods `get_particle_id_map`, `calculate_HH`, `calculate_net_charge`, `center_object_in_simulation_box` now support all template types in pyMBE, including hydrogels. Some of these methods have been renamed to expose directly in the API this change in behavior. (#147)
36+
- Fixed edge cases in rigid-body initialization used by nanoparticle creation to improve robustness of newly created nanoparticle objects. (#148)
2937

3038

3139
### Removed

pyMBE/lib/handy_functions.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,90 @@ def get_number_of_particles(espresso_system, ptype):
342342
kwargs = {"type": ptype}
343343
return espresso_system.number_of_particles(*args, **kwargs)
344344

345+
def generate_lattice_positions(lattice_type, number_of_sites, lattice_constant=1.0, box_length=None, origin=None):
346+
"""
347+
Generate lattice positions for a requested lattice type and number of sites.
348+
349+
Args:
350+
lattice_type ('str'):
351+
Lattice type identifier. Supported values are:
352+
- ``"sc"`` (simple cubic)
353+
- ``"bcc"`` (body-centered cubic)
354+
- ``"fcc"`` (face-centered cubic)
355+
356+
number_of_sites ('int'):
357+
Number of lattice positions to generate.
358+
359+
lattice_constant ('float', optional):
360+
Lattice constant. Used when ``box_length`` is not provided.
361+
Must be positive.
362+
363+
box_length ('float', optional):
364+
If provided, lattice positions are fitted into a cubic box of side
365+
``box_length`` by choosing the cell spacing automatically.
366+
367+
origin ('list[float]', optional):
368+
Origin shift applied to all generated coordinates.
369+
Defaults to ``[0.0, 0.0, 0.0]``.
370+
371+
Returns:
372+
('list[list[float]]'):
373+
List of 3D lattice positions.
374+
375+
Raises:
376+
ValueError:
377+
If ``lattice_type`` is unsupported, ``number_of_sites`` is negative,
378+
or geometric inputs are invalid.
379+
"""
380+
lattice_key = lattice_type.lower()
381+
basis_map = {
382+
"sc": np.array([[0.0, 0.0, 0.0]]),
383+
"bcc": np.array([[0.0, 0.0, 0.0],
384+
[0.5, 0.5, 0.5]]),
385+
"fcc": np.array([[0.0, 0.0, 0.0],
386+
[0.0, 0.5, 0.5],
387+
[0.5, 0.0, 0.5],
388+
[0.5, 0.5, 0.0]]),
389+
}
390+
if lattice_key not in basis_map:
391+
raise ValueError(f"Unsupported lattice_type '{lattice_type}'. Supported values are {list(basis_map.keys())}.")
392+
if number_of_sites < 0:
393+
raise ValueError("number_of_sites must be a non-negative integer.")
394+
if number_of_sites == 0:
395+
return []
396+
if origin is None:
397+
origin = np.zeros(3)
398+
else:
399+
origin = np.array(origin, dtype=float)
400+
if origin.shape != (3,):
401+
raise ValueError("origin must be a 3D coordinate [x, y, z].")
402+
403+
points_per_cell = len(basis_map[lattice_key])
404+
n_cells = int(np.ceil((number_of_sites / points_per_cell) ** (1.0 / 3.0)))
405+
if n_cells <= 0:
406+
n_cells = 1
407+
408+
if box_length is not None:
409+
if box_length <= 0:
410+
raise ValueError("box_length must be positive.")
411+
spacing = float(box_length) / n_cells
412+
else:
413+
if lattice_constant <= 0:
414+
raise ValueError("lattice_constant must be positive.")
415+
spacing = float(lattice_constant)
416+
417+
basis = basis_map[lattice_key]
418+
positions = []
419+
for i in range(n_cells):
420+
for j in range(n_cells):
421+
for k in range(n_cells):
422+
cell_origin = np.array([i, j, k], dtype=float) * spacing
423+
for site in basis:
424+
positions.append((cell_origin + site * spacing + origin).tolist())
425+
if len(positions) == number_of_sites:
426+
return positions
427+
return positions
428+
345429
def get_residues_from_topology_dict(topology_dict, model):
346430
"""
347431
Groups beads from a topology dictionary into residues and assigns residue names.
@@ -751,4 +835,4 @@ def setup_electrostatic_interactions(units, espresso_system, kT, c_salt=None, so
751835
espresso_system.actors.add(coulomb)
752836
else:
753837
espresso_system.electrostatics.solver = coulomb
754-
logging.debug("*** Electrostatics successfully added to the system ***")
838+
logging.debug("*** Electrostatics successfully added to the system ***")

pyMBE/lib/nanoparticle_tools.py

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
2+
#
3+
# Copyright (C) 2024 pyMBE-dev team
4+
#
5+
# This file is part of pyMBE.
6+
#
7+
# pyMBE is free software: you can redistribute it and/or modify
8+
# it under the terms of the GNU General Public License as published by
9+
# the Free Software Foundation, either version 3 of the License, or
10+
# (at your option) any later version.
11+
#
12+
# pyMBE is distributed in the hope that it will be useful,
13+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
# GNU General Public License for more details.
16+
#
17+
# You should have received a copy of the GNU General Public License
18+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
19+
20+
import numpy as np
21+
import random
22+
from scipy.spatial import cKDTree
23+
24+
# Distributing points evenly on the surface of a sphere
25+
26+
def uniform_distribution_sites_on_sphere(number_of_edges=2, tolerance=1e-6):
27+
"""
28+
Distribute points approximately uniformly on the surface of a unit sphere.
29+
30+
The algorithm uses iterative force-based relaxation, conceptually similar
31+
to the Thomson problem.
32+
33+
Args:
34+
number_of_edges ('int', optional):
35+
Number of points to distribute on the sphere surface.
36+
37+
tolerance ('float', optional):
38+
Convergence tolerance for the iterative relaxation.
39+
40+
Returns:
41+
('list[tuple[float, float, float]]'):
42+
Point coordinates on the unit sphere centered at ``[0, 0, 0]``.
43+
44+
Notes:
45+
– Thomson problem — Wikipedia (overview of the physics problem), Wikipedia.
46+
– Simple schemes for uniform point distribution. Cheng Guan Koay, J Comput Sci. 2011 Dec;2(4):377–381. doi: 10.1016/j.jocs.2011.06.007.
47+
"""
48+
49+
# Generates initial configuration
50+
51+
random.seed(42)
52+
edges = []
53+
for i in range(number_of_edges):
54+
55+
theta = random.random() * 2*np.pi
56+
phi = np.arcsin(random.random() * 2 - 1)
57+
edges.append((np.cos(theta)*np.cos(phi), np.sin(theta)*np.cos(phi), np.sin(phi)))
58+
59+
# Iterates until fullfil the tolerance
60+
61+
while 1:
62+
# Determine the total force acting on each point.
63+
forces = []
64+
for i in range(len(edges)):
65+
p = edges[i]
66+
f = (0,0,0)
67+
ftotal = 0
68+
for j in range(len(edges)):
69+
if j == i: continue
70+
q = edges[j]
71+
72+
# Find the distance vector, and its length.
73+
dv = (p[0]-q[0], p[1]-q[1], p[2]-q[2])
74+
dl = np.sqrt(dv[0]**2 + dv[1]**2 + dv[2]**2)
75+
76+
# The force vector is dv divided by dl^3. (We divide by dl once to make dv a unit vector, then by dl^2 to make its length correspond to the force.)
77+
dl3 = dl ** 3
78+
fv = (dv[0]/dl3, dv[1]/dl3, dv[2]/dl3)
79+
80+
# Add to the total force on the point p.
81+
f = (f[0]+fv[0], f[1]+fv[1], f[2]+fv[2])
82+
83+
# Add in the forces array.
84+
forces.append(f)
85+
86+
# Add to the running sum of the total forces/distances.
87+
ftotal = ftotal + np.sqrt(f[0]**2 + f[1]**2 + f[2]**2)
88+
89+
# Scale the forces to ensure the points do not move too far in one go. Otherwise there will be chaotic jumping around and never any convergence.
90+
91+
if ftotal > 0.25:
92+
fscale = 0.25 / ftotal
93+
else:
94+
fscale = 1
95+
96+
# Move each point, and normalise. While we do this, also track the distance each point ends up moving.
97+
98+
dist = 0
99+
for i in range(len(edges)):
100+
p = edges[i]
101+
f = forces[i]
102+
p2 = (p[0] + f[0]*fscale, p[1] + f[1]*fscale, p[2] + f[2]*fscale)
103+
pl = np.sqrt(p2[0]**2 + p2[1]**2 + p2[2]**2)
104+
p2 = (p2[0] / pl, p2[1] / pl, p2[2] / pl)
105+
dv = (p[0]-p2[0], p[1]-p2[1], p[2]-p2[2])
106+
dl = np.sqrt(dv[0]**2 + dv[1]**2 + dv[2]**2)
107+
dist = dist + dl
108+
edges[i] = p2
109+
110+
# Check for convergence and finish.
111+
112+
if dist < tolerance:
113+
break
114+
115+
return edges
116+
117+
# Auxiliary functions to calculate the patchy distribution
118+
119+
def calculate_distance_vector_point(A,p):
120+
"""
121+
Compute Euclidean distances between a set of 3D points and one 3D point.
122+
123+
Args:
124+
A ('iterable'):
125+
Iterable of 3D points.
126+
127+
p ('iterable'):
128+
Reference 3D point.
129+
130+
Returns:
131+
('list[float]'):
132+
Euclidean distance from each point in ``A`` to ``p``.
133+
"""
134+
C = []
135+
for a in A:
136+
C.append(((a[0] - p[0])**2 + (a[1] - p[1])**2 + (a[2] - p[2])**2)**(1/2))
137+
return C
138+
139+
def define_patch(points,central_point,patch_size):
140+
"""
141+
Select the nearest ``patch_size`` points around a central point.
142+
143+
Args:
144+
points ('iterable'):
145+
Iterable of candidate 3D points.
146+
147+
central_point ('iterable'):
148+
3D point used as the patch center.
149+
150+
patch_size ('int'):
151+
Number of points to include in the patch.
152+
153+
Returns:
154+
('tuple[list[float], list[tuple[float, float, float]]]'):
155+
Pair with:
156+
- Distances from all input points to ``central_point``.
157+
- Coordinates of the selected patch points.
158+
"""
159+
site_positions = []
160+
distance_to_central_point = calculate_distance_vector_point(points,central_point)
161+
points_index = sorted(range(len(distance_to_central_point)), key=lambda sub: distance_to_central_point[sub])[:patch_size]
162+
for index in points_index:
163+
site_positions.append((points[index][0],points[index][1],points[index][2]))
164+
return distance_to_central_point, site_positions
165+
166+
def check_patch_overlaps(sites_positions,number_patches):
167+
"""
168+
Check for overlapping site coordinates between patches.
169+
170+
Args:
171+
sites_positions ('list'):
172+
List containing one list of coordinates per patch.
173+
174+
number_patches ('int'):
175+
Number of patches to compare.
176+
177+
Returns:
178+
('int'):
179+
Returns ``0`` when no overlap is detected.
180+
181+
Raises:
182+
ValueError:
183+
If overlapping coordinates are found between any pair of patches.
184+
"""
185+
overlapped_sites = []
186+
for i in range(number_patches-1):
187+
for j in range(i + 1, number_patches):
188+
overlapped_sites.append(set(sites_positions[i]) & set(sites_positions[j]))
189+
for overlap in overlapped_sites:
190+
if len(overlap) != 0:
191+
raise ValueError("The patchies are overlapping in {} sites. Please adjust the angle between them.\n".format(len(overlap)));
192+
else:
193+
0
194+
return 0
195+
196+
def calculate_distance_between_points_on_sphere(points):
197+
"""
198+
Compute nearest-neighbor distance statistics for points on a sphere.
199+
200+
Args:
201+
points ('iterable'):
202+
Nested iterable with 3D point coordinates.
203+
204+
Returns:
205+
('tuple[float, float, float]'):
206+
Tuple ``(mean, std, stderr)`` of nearest-neighbor distances.
207+
"""
208+
points = np.vstack(points)
209+
tree = cKDTree(points)
210+
distances, _ = tree.query(points, k=7) # Assuming 6 neighbors plus the point itself k = 7
211+
nearest_neighbors_dist = distances[:, 1] # Removing self (distance = 0)
212+
avg_dis = np.mean(nearest_neighbors_dist)
213+
dev_dis = np.std(nearest_neighbors_dist)
214+
err_dis = dev_dis / np.sqrt(len(nearest_neighbors_dist))
215+
return avg_dis, dev_dis, err_dis
216+
217+
def calculate_dipole_moment(charges, positions):
218+
"""
219+
Compute dipole moment for a set of point charges.
220+
221+
Args:
222+
charges ('iterable'):
223+
Charge values.
224+
225+
positions ('iterable'):
226+
3D coordinates matching ``charges``.
227+
228+
Returns:
229+
('tuple[numpy.ndarray, float]'):
230+
Dipole vector and dipole magnitude.
231+
"""
232+
dipole_moment = np.sum(np.array(charges)[:, None] * np.array(positions), axis=0)
233+
dipole_magnitude = np.linalg.norm(dipole_moment)
234+
return dipole_moment, dipole_magnitude
235+
236+
def calculate_quadrupole_moment(charges, positions):
237+
"""
238+
Compute quadrupole moment tensor for a set of point charges.
239+
240+
Args:
241+
charges ('iterable'):
242+
Charge values.
243+
244+
positions ('iterable'):
245+
3D coordinates matching ``charges``.
246+
247+
Returns:
248+
('tuple[numpy.ndarray, float, numpy.ndarray]'):
249+
Quadrupole tensor (3x3), Frobenius norm, and eigenvalues.
250+
"""
251+
Q = np.zeros((3, 3))
252+
positions = np.array(positions)
253+
for q, r in zip(charges, positions):
254+
r_outer = np.outer(r, r)
255+
Q += q * (3 * r_outer - np.eye(3) * np.dot(r, r))
256+
quadrupole_magnitude = np.linalg.norm(Q)
257+
eigenvalues, _ = np.linalg.eigh(Q)
258+
return Q, quadrupole_magnitude, eigenvalues

0 commit comments

Comments
 (0)