pystarc 1.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pystarc/__init__.py +9 -0
- pystarc/analysis/__init__.py +0 -0
- pystarc/analysis/convergence.py +182 -0
- pystarc/aux/__init__.py +0 -0
- pystarc/aux/aux_tools.py +151 -0
- pystarc/cli/__init__.py +0 -0
- pystarc/cli/main.py +151 -0
- pystarc/forces/__init__.py +0 -0
- pystarc/forces/electrostatic/__init__.py +0 -0
- pystarc/forces/electrostatic/grid_force.py +327 -0
- pystarc/forces/engine.py +680 -0
- pystarc/forces/gpu_batch_engine.py +657 -0
- pystarc/forces/lj.py +228 -0
- pystarc/forces/multipole.py +184 -0
- pystarc/forces/multipole_farfield.py +225 -0
- pystarc/global_defs/__init__.py +0 -0
- pystarc/global_defs/constants.py +85 -0
- pystarc/hydrodynamics/__init__.py +0 -0
- pystarc/hydrodynamics/mc_hydro_radius.py +266 -0
- pystarc/hydrodynamics/rotne_prager.py +215 -0
- pystarc/lib/__init__.py +0 -0
- pystarc/lib/numerical.py +145 -0
- pystarc/molsystem/__init__.py +0 -0
- pystarc/molsystem/system_state.py +97 -0
- pystarc/motion/__init__.py +0 -0
- pystarc/motion/adaptive_time_step.py +170 -0
- pystarc/motion/do_bd_step.py +195 -0
- pystarc/multi_GPU/__init__.py +0 -0
- pystarc/multi_GPU/combine_data.py +368 -0
- pystarc/multi_GPU/multi_GPU_runs.py +100 -0
- pystarc/pathways/__init__.py +0 -0
- pystarc/pathways/reaction_interface.py +100 -0
- pystarc/pipeline/__init__.py +0 -0
- pystarc/pipeline/extract.py +98 -0
- pystarc/pipeline/geometry.py +448 -0
- pystarc/pipeline/gho_injection.py +341 -0
- pystarc/pipeline/input_parser.py +334 -0
- pystarc/pipeline/make_pqr.py +217 -0
- pystarc/pipeline/output_writer.py +334 -0
- pystarc/pipeline/parameterize.py +104 -0
- pystarc/pipeline/pipeline.py +399 -0
- pystarc/pipeline/prepare_bd_surface.py +783 -0
- pystarc/pipeline/run_apbs.py +450 -0
- pystarc/simulation/__init__.py +0 -0
- pystarc/simulation/coffdrop_chain.py +645 -0
- pystarc/simulation/coffdrop_params.py +538 -0
- pystarc/simulation/diffusional_rotation.py +991 -0
- pystarc/simulation/gpu_batch_simulator.py +1377 -0
- pystarc/simulation/nam_simulator.py +649 -0
- pystarc/simulation/outer_propagator.py +401 -0
- pystarc/simulation/parallel.py +464 -0
- pystarc/simulation/step_near_surface.py +130 -0
- pystarc/simulation/we_simulator.py +466 -0
- pystarc/simulation/wiener.py +145 -0
- pystarc/structures/__init__.py +0 -0
- pystarc/structures/molecules.py +236 -0
- pystarc/structures/pqr_io.py +64 -0
- pystarc/transforms/__init__.py +0 -0
- pystarc/transforms/quaternion.py +187 -0
- pystarc/xml_io/__init__.py +0 -0
- pystarc/xml_io/simulation_io.py +130 -0
- pystarc-1.1.0.dist-info/METADATA +104 -0
- pystarc-1.1.0.dist-info/RECORD +67 -0
- pystarc-1.1.0.dist-info/WHEEL +5 -0
- pystarc-1.1.0.dist-info/entry_points.txt +5 -0
- pystarc-1.1.0.dist-info/licenses/LICENSE +21 -0
- pystarc-1.1.0.dist-info/top_level.txt +1 -0
pystarc/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convergence analysis for Brownian Dynamics simulations
|
|
3
|
+
======================================================
|
|
4
|
+
|
|
5
|
+
Physical Background
|
|
6
|
+
-------------------
|
|
7
|
+
BD trajectories are independent Bernoulli trials. Each trajectory
|
|
8
|
+
starts from a fresh random position on the b-surface with an
|
|
9
|
+
independent random number seed, and terminates by either reacting
|
|
10
|
+
(success, probability P_rxn) or escaping (failure, probability
|
|
11
|
+
1 - P_rxn).
|
|
12
|
+
|
|
13
|
+
Because the trials are independent and identically distributed
|
|
14
|
+
(i.i.d.), the standard error of the mean is exact.
|
|
15
|
+
|
|
16
|
+
SE(P_rxn) = √[P_rxn × (1 - P_rxn) / N]
|
|
17
|
+
|
|
18
|
+
Convergence criterion
|
|
19
|
+
---------------------
|
|
20
|
+
The relative standard error (RSE = SE / P_rxn) directly quantifies
|
|
21
|
+
the precision of k_on.
|
|
22
|
+
|
|
23
|
+
RSE = √[(1 - P_rxn) / (N × P_rxn)]
|
|
24
|
+
|
|
25
|
+
Since RSE ∝ 1/√N, the number of trajectories needed for a target
|
|
26
|
+
precision is given by
|
|
27
|
+
|
|
28
|
+
N_needed = (1 - P_rxn) / (P_rxn × RSE_target²)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
Wilson score confidence interval
|
|
33
|
+
--------------------------------
|
|
34
|
+
The 95% CI on P_rxn is computed using the Wilson score interval
|
|
35
|
+
|
|
36
|
+
P ∈ [p̂ + z²/2n ± z√(p̂(1-p̂)/n + z²/4n²)] / (1 + z²/n)
|
|
37
|
+
|
|
38
|
+
This is preferred over the normal approximation (p̂ ± 2σ) because
|
|
39
|
+
it provides guaranteed coverage even when P_rxn << 0.05 or N is
|
|
40
|
+
small. The normal approximation gives negative lower bounds for
|
|
41
|
+
small P_rxn, which is unphysical.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
from typing import Optional
|
|
45
|
+
import math
|
|
46
|
+
import json
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def analyse_convergence(
|
|
51
|
+
n_reacted: int,
|
|
52
|
+
n_escaped: int,
|
|
53
|
+
k_b: float,
|
|
54
|
+
tol: float = 0.05,
|
|
55
|
+
conv_factor: float = 6.022e8,
|
|
56
|
+
work_dir: str = ".",
|
|
57
|
+
) -> dict:
|
|
58
|
+
"""
|
|
59
|
+
Run convergence analysis on completed BD simulation.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
n_reacted : int
|
|
64
|
+
Total trajectories that reacted.
|
|
65
|
+
n_escaped : int
|
|
66
|
+
Total trajectories that escaped.
|
|
67
|
+
k_b : float
|
|
68
|
+
Encounter rate constant (A^3/ps).
|
|
69
|
+
tol : float
|
|
70
|
+
Relative SE threshold for convergence (default 0.05 = 5%).
|
|
71
|
+
conv_factor : float
|
|
72
|
+
Unit conversion A^3/ps -> M-1 s-1.
|
|
73
|
+
work_dir : str
|
|
74
|
+
Directory to save convergence report.
|
|
75
|
+
|
|
76
|
+
Returns
|
|
77
|
+
-------
|
|
78
|
+
dict with convergence results.
|
|
79
|
+
"""
|
|
80
|
+
N = n_reacted + n_escaped
|
|
81
|
+
if N == 0:
|
|
82
|
+
return {"converged": False, "reason": "no completed trajectories"}
|
|
83
|
+
P = n_reacted / N
|
|
84
|
+
k_on = conv_factor * k_b * P
|
|
85
|
+
# SE and relative SE
|
|
86
|
+
if P > 0 and P < 1:
|
|
87
|
+
SE = math.sqrt(P * (1 - P) / N)
|
|
88
|
+
relative_SE = SE / P
|
|
89
|
+
elif P == 0:
|
|
90
|
+
SE = 0.0
|
|
91
|
+
relative_SE = float("inf")
|
|
92
|
+
else:
|
|
93
|
+
SE = 0.0
|
|
94
|
+
relative_SE = 0.0
|
|
95
|
+
SE_kon = conv_factor * k_b * SE
|
|
96
|
+
# Wilson 95% CI
|
|
97
|
+
z = 1.96
|
|
98
|
+
denom = 1 + z**2 / N
|
|
99
|
+
centre = (P + z**2 / (2 * N)) / denom
|
|
100
|
+
spread = z * math.sqrt(P * (1 - P) / N + z**2 / (4 * N**2)) / denom
|
|
101
|
+
wilson_lo = max(0.0, centre - spread)
|
|
102
|
+
wilson_hi = min(1.0, centre + spread)
|
|
103
|
+
wilson_lo_kon = conv_factor * k_b * wilson_lo
|
|
104
|
+
wilson_hi_kon = conv_factor * k_b * wilson_hi
|
|
105
|
+
# Convergence verdict
|
|
106
|
+
converged = relative_SE < tol if P > 0 else False
|
|
107
|
+
# N needed for target tolerances
|
|
108
|
+
targets = {}
|
|
109
|
+
if 0 < P < 1:
|
|
110
|
+
for target_tol in [0.10, 0.05, 0.01]:
|
|
111
|
+
n_needed = int(math.ceil((1 - P) / (P * target_tol**2)))
|
|
112
|
+
targets[f"{int(target_tol*100)}%"] = n_needed
|
|
113
|
+
result = {
|
|
114
|
+
"N": N,
|
|
115
|
+
"n_reacted": n_reacted,
|
|
116
|
+
"n_escaped": n_escaped,
|
|
117
|
+
"P_rxn": P,
|
|
118
|
+
"SE": SE,
|
|
119
|
+
"relative_SE": relative_SE,
|
|
120
|
+
"relative_SE_pct": relative_SE * 100 if P > 0 else float("inf"),
|
|
121
|
+
"k_on": k_on,
|
|
122
|
+
"SE_kon": SE_kon,
|
|
123
|
+
"wilson_CI": [wilson_lo_kon, wilson_hi_kon],
|
|
124
|
+
"wilson_CI_P": [wilson_lo, wilson_hi],
|
|
125
|
+
"converged": converged,
|
|
126
|
+
"tol": tol,
|
|
127
|
+
"tol_pct": tol * 100,
|
|
128
|
+
"N_needed": targets,
|
|
129
|
+
}
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def print_convergence(result: dict) -> str:
|
|
134
|
+
"""Print convergence analysis to terminal and return as string."""
|
|
135
|
+
lines = []
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append(" Convergence analysis")
|
|
138
|
+
if "N" not in result:
|
|
139
|
+
lines.append(f" {result.get('reason', 'no data')}")
|
|
140
|
+
text = "\n".join(lines)
|
|
141
|
+
print(text)
|
|
142
|
+
return text
|
|
143
|
+
lines.append(f" N completed = {result['N']:,}")
|
|
144
|
+
lines.append(f" P_rxn = {result['P_rxn']:.6f}")
|
|
145
|
+
lines.append(f" SE(P_rxn) = {result['SE']:.6f}")
|
|
146
|
+
if result["P_rxn"] > 0:
|
|
147
|
+
lines.append(
|
|
148
|
+
f" Relative SE = {result['relative_SE_pct']:.2f}%"
|
|
149
|
+
f" - k_on known to ±{result['relative_SE_pct']:.2f}%"
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
lines.append(f" Relative SE = inf (P_rxn = 0, no reactions)")
|
|
153
|
+
lines.append(
|
|
154
|
+
f" Wilson 95% CI = [{result['wilson_CI'][0]:.4e}, "
|
|
155
|
+
f"{result['wilson_CI'][1]:.4e}] M⁻¹s⁻¹"
|
|
156
|
+
)
|
|
157
|
+
tol_pct = result["tol_pct"]
|
|
158
|
+
if result["converged"]:
|
|
159
|
+
lines.append(
|
|
160
|
+
f" Converged (relative SE {result['relative_SE_pct']:.2f}% < {tol_pct:.0f}% threshold)"
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
lines.append(
|
|
164
|
+
f" Not converged (relative SE {result['relative_SE_pct']:.2f}% > {tol_pct:.0f}% threshold)"
|
|
165
|
+
)
|
|
166
|
+
if result["N_needed"]:
|
|
167
|
+
lines.append(f" Trajectories needed")
|
|
168
|
+
for label, n in result["N_needed"].items():
|
|
169
|
+
status = "done" if result["N"] >= n else "need more"
|
|
170
|
+
lines.append(f" For ±{label} relative SE: {n:,} ({status})")
|
|
171
|
+
text = "\n".join(lines)
|
|
172
|
+
print(text)
|
|
173
|
+
return text
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def save_convergence(result: dict, work_dir: str = ".") -> None:
|
|
177
|
+
import json, os
|
|
178
|
+
|
|
179
|
+
path = os.path.join(work_dir, "convergence.json")
|
|
180
|
+
with open(path, "w") as f:
|
|
181
|
+
json.dump(result, f, indent=2, default=str)
|
|
182
|
+
print(f" Convergence saved -> {path}")
|
pystarc/aux/__init__.py
ADDED
|
File without changes
|
pystarc/aux/aux_tools.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auxiliary preprocessing tools for PySTARC.
|
|
3
|
+
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from pystarc.structures.molecules import Atom, Molecule, BoundingBox
|
|
8
|
+
from pystarc.global_defs.constants import BJERRUM_LENGTH
|
|
9
|
+
from typing import List, Tuple, Optional, Dict
|
|
10
|
+
from pystarc.global_defs.constants import PI
|
|
11
|
+
import numpy as np
|
|
12
|
+
import math
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# bounding_box
|
|
16
|
+
def bounding_box(mol: Molecule, padding: float = 5.0) -> BoundingBox:
|
|
17
|
+
"""
|
|
18
|
+
Compute axis-aligned bounding box of a molecule with optional padding.
|
|
19
|
+
"""
|
|
20
|
+
return BoundingBox.from_molecule(mol, padding=padding)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# surface_spheres
|
|
24
|
+
def surface_spheres(
|
|
25
|
+
mol: Molecule, probe_radius: float = 1.4, n_points: int = 92
|
|
26
|
+
) -> List[np.ndarray]:
|
|
27
|
+
"""
|
|
28
|
+
Generate surface probe positions using a Fibonacci sphere around each atom.
|
|
29
|
+
Returns list of (x,y,z) probe positions on the molecular surface.
|
|
30
|
+
"""
|
|
31
|
+
positions = []
|
|
32
|
+
golden = (1 + math.sqrt(5)) / 2
|
|
33
|
+
for atom in mol.atoms:
|
|
34
|
+
r = atom.radius + probe_radius
|
|
35
|
+
c = atom.position
|
|
36
|
+
for i in range(n_points):
|
|
37
|
+
theta = math.acos(1 - 2 * (i + 0.5) / n_points)
|
|
38
|
+
phi = 2 * PI * i / golden
|
|
39
|
+
x = c[0] + r * math.sin(theta) * math.cos(phi)
|
|
40
|
+
y = c[1] + r * math.sin(theta) * math.sin(phi)
|
|
41
|
+
z = c[2] + r * math.cos(theta)
|
|
42
|
+
# Check not inside any other atom
|
|
43
|
+
p = np.array([x, y, z])
|
|
44
|
+
buried = any(
|
|
45
|
+
np.linalg.norm(p - a.position) < a.radius + probe_radius
|
|
46
|
+
for a in mol.atoms
|
|
47
|
+
if a is not atom
|
|
48
|
+
)
|
|
49
|
+
if not buried:
|
|
50
|
+
positions.append(p)
|
|
51
|
+
return positions
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# lumped_charges
|
|
55
|
+
def lumped_charges(
|
|
56
|
+
mol: Molecule, grid_spacing: float = 2.0
|
|
57
|
+
) -> List[Tuple[np.ndarray, float]]:
|
|
58
|
+
"""
|
|
59
|
+
Coarse-grain atomic charges onto a regular grid by nearest-grid-point.
|
|
60
|
+
Returns list of (position, charge) tuples for non-zero grid points.
|
|
61
|
+
"""
|
|
62
|
+
if not mol.atoms:
|
|
63
|
+
return []
|
|
64
|
+
bb = bounding_box(mol, padding=grid_spacing)
|
|
65
|
+
# Build grid
|
|
66
|
+
xs = np.arange(bb.xmin, bb.xmax + grid_spacing, grid_spacing)
|
|
67
|
+
ys = np.arange(bb.ymin, bb.ymax + grid_spacing, grid_spacing)
|
|
68
|
+
zs = np.arange(bb.zmin, bb.zmax + grid_spacing, grid_spacing)
|
|
69
|
+
grid: Dict[Tuple[int, int, int], float] = {}
|
|
70
|
+
for atom in mol.atoms:
|
|
71
|
+
if atom.charge == 0.0:
|
|
72
|
+
continue
|
|
73
|
+
ix = int(round((atom.x - bb.xmin) / grid_spacing))
|
|
74
|
+
iy = int(round((atom.y - bb.ymin) / grid_spacing))
|
|
75
|
+
iz = int(round((atom.z - bb.zmin) / grid_spacing))
|
|
76
|
+
key = (ix, iy, iz)
|
|
77
|
+
grid[key] = grid.get(key, 0.0) + atom.charge
|
|
78
|
+
result = []
|
|
79
|
+
for (ix, iy, iz), q in grid.items():
|
|
80
|
+
if abs(q) > 1e-8:
|
|
81
|
+
pos = np.array(
|
|
82
|
+
[
|
|
83
|
+
bb.xmin + ix * grid_spacing,
|
|
84
|
+
bb.ymin + iy * grid_spacing,
|
|
85
|
+
bb.zmin + iz * grid_spacing,
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
result.append((pos, q))
|
|
89
|
+
return result
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# electrostatic_center
|
|
93
|
+
def electrostatic_center(mol: Molecule) -> np.ndarray:
|
|
94
|
+
"""
|
|
95
|
+
Charge-weighted center of a molecule.
|
|
96
|
+
Falls back to geometric centroid if total charge is zero.
|
|
97
|
+
"""
|
|
98
|
+
total_q = sum(abs(a.charge) for a in mol.atoms)
|
|
99
|
+
if total_q < 1e-10:
|
|
100
|
+
return mol.centroid()
|
|
101
|
+
pos = mol.positions_array()
|
|
102
|
+
charges = np.abs(mol.charges_array())
|
|
103
|
+
return (pos * charges[:, None]).sum(axis=0) / total_q
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# hydrodynamic_radius
|
|
107
|
+
def hydrodynamic_radius_from_rg(mol: Molecule) -> float:
|
|
108
|
+
"""
|
|
109
|
+
Approximate hydrodynamic radius from radius of gyration.
|
|
110
|
+
r_h ≈ 0.77 × r_g (empirical for globular proteins).
|
|
111
|
+
"""
|
|
112
|
+
return 0.77 * mol.radius_of_gyration()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def hydrodynamic_radius_from_surface(mol: Molecule) -> float:
|
|
116
|
+
"""
|
|
117
|
+
Approximate hydrodynamic radius as bounding radius of the molecule.
|
|
118
|
+
"""
|
|
119
|
+
return mol.bounding_radius()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# contact_distances
|
|
123
|
+
def contact_distances(
|
|
124
|
+
mol1: Molecule, mol2: Molecule, cutoff: float = 8.0
|
|
125
|
+
) -> List[Tuple[int, int, float]]:
|
|
126
|
+
"""
|
|
127
|
+
Return all atom pairs (i, j, dist) within cutoff Å.
|
|
128
|
+
Used to auto-generate reaction contacts.
|
|
129
|
+
"""
|
|
130
|
+
pairs = []
|
|
131
|
+
for i, a1 in enumerate(mol1.atoms):
|
|
132
|
+
for j, a2 in enumerate(mol2.atoms):
|
|
133
|
+
d = a1.distance_to(a2)
|
|
134
|
+
if d <= cutoff:
|
|
135
|
+
pairs.append((i, j, d))
|
|
136
|
+
pairs.sort(key=lambda t: t[2])
|
|
137
|
+
return pairs
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# born_integral
|
|
141
|
+
def born_integral(
|
|
142
|
+
charge: float, radius: float, eps_in: float = 4.0, eps_out: float = 78.54
|
|
143
|
+
) -> float:
|
|
144
|
+
"""
|
|
145
|
+
Born solvation energy of a sphere:
|
|
146
|
+
ΔG_Born = -(q²/8π ε₀) × (1/ε_in - 1/ε_out) / r [kBT]
|
|
147
|
+
Returns energy in kBT (using Bjerrum length scale).
|
|
148
|
+
"""
|
|
149
|
+
if radius < 1e-10:
|
|
150
|
+
return 0.0
|
|
151
|
+
return -(charge**2 * BJERRUM_LENGTH / (2 * radius)) * (1.0 / eps_in - 1.0 / eps_out)
|
pystarc/cli/__init__.py
ADDED
|
File without changes
|
pystarc/cli/main.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for PySTARC.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from pystarc.simulation.nam_simulator import NAMSimulator, NAMParameters
|
|
7
|
+
from pystarc.hydrodynamics.rotne_prager import MobilityTensor
|
|
8
|
+
from pystarc.xml_io.simulation_io import parse_reaction_xml
|
|
9
|
+
from pystarc.forces.electrostatic.grid_force import DXGrid
|
|
10
|
+
from pystarc.simulation.nam_simulator import zero_force
|
|
11
|
+
from pystarc.structures.pqr_io import parse_pqr
|
|
12
|
+
from pystarc.aux.aux_tools import bounding_box
|
|
13
|
+
import xml.etree.ElementTree as ET
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import numpy as np
|
|
16
|
+
import click
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.version_option(package_name="pystarc")
|
|
22
|
+
def cli():
|
|
23
|
+
"""PySTARC - Python Simulation Toolkit for Association Rate Constants"""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# nam_simulation
|
|
28
|
+
@cli.command("nam_simulation")
|
|
29
|
+
@click.option("--mol1", required=True, help="PQR file for molecule 1")
|
|
30
|
+
@click.option("--mol2", required=True, help="PQR file for molecule 2")
|
|
31
|
+
@click.option("--rxn", required=True, help="Reaction XML file")
|
|
32
|
+
@click.option("--n", default=1000, show_default=True, help="Number of trajectories")
|
|
33
|
+
@click.option("--dt", default=0.2, show_default=True, help="Time step (ps)")
|
|
34
|
+
@click.option("--r-start", default=100.0, show_default=True, help="Start radius (Å)")
|
|
35
|
+
@click.option("--dx", multiple=True, help="APBS .dx grid file(s)")
|
|
36
|
+
@click.option("--seed", default=None, type=int, help="Random seed")
|
|
37
|
+
@click.option("--verbose", is_flag=True, help="Print progress")
|
|
38
|
+
@click.option("--output", default="results.xml", help="Output XML file")
|
|
39
|
+
def nam_simulation(mol1, mol2, rxn, n, dt, r_start, dx, seed, verbose, output):
|
|
40
|
+
"""Run a NAM Brownian dynamics simulation."""
|
|
41
|
+
click.echo(f"Loading molecules …")
|
|
42
|
+
m1 = parse_pqr(mol1)
|
|
43
|
+
m2 = parse_pqr(mol2)
|
|
44
|
+
click.echo(f" mol1: {m1}")
|
|
45
|
+
click.echo(f" mol2: {m2}")
|
|
46
|
+
pathways = parse_reaction_xml(rxn)
|
|
47
|
+
click.echo(f" reactions: {pathways}")
|
|
48
|
+
# Mobility from bounding radii
|
|
49
|
+
r1 = m1.bounding_radius()
|
|
50
|
+
r2 = m2.bounding_radius()
|
|
51
|
+
mobility = MobilityTensor.from_radii(r1, r2)
|
|
52
|
+
click.echo(f" mobility: {mobility}")
|
|
53
|
+
# Load DX grids if provided
|
|
54
|
+
grids = []
|
|
55
|
+
for dx_file in dx:
|
|
56
|
+
g = DXGrid.from_file(dx_file)
|
|
57
|
+
grids.append(g)
|
|
58
|
+
click.echo(f" loaded grid: {g}")
|
|
59
|
+
# Build force function
|
|
60
|
+
if grids:
|
|
61
|
+
|
|
62
|
+
def force_fn(mol_1, mol_2):
|
|
63
|
+
force = np.zeros(3)
|
|
64
|
+
torque = np.zeros(3)
|
|
65
|
+
energy = 0.0
|
|
66
|
+
for grid in grids:
|
|
67
|
+
for atom in mol_2.atoms:
|
|
68
|
+
if abs(atom.charge) < 1e-9:
|
|
69
|
+
continue
|
|
70
|
+
f = grid.force_on_charge(atom.position, atom.charge)
|
|
71
|
+
force += f
|
|
72
|
+
energy += grid.interpolate(atom.position) * atom.charge
|
|
73
|
+
# torque = r × f
|
|
74
|
+
r = atom.position - mol_2.centroid()
|
|
75
|
+
torque += np.cross(r, f)
|
|
76
|
+
return force, torque, energy
|
|
77
|
+
|
|
78
|
+
else:
|
|
79
|
+
force_fn = zero_force
|
|
80
|
+
params = NAMParameters(
|
|
81
|
+
n_trajectories=n,
|
|
82
|
+
dt=dt,
|
|
83
|
+
r_start=r_start,
|
|
84
|
+
seed=seed,
|
|
85
|
+
verbose=verbose,
|
|
86
|
+
)
|
|
87
|
+
sim = NAMSimulator(m1, m2, mobility, pathways, params, force_fn)
|
|
88
|
+
click.echo(f"\nRunning {n} trajectories …")
|
|
89
|
+
result = sim.run()
|
|
90
|
+
click.echo(f"\n{'-'*50}")
|
|
91
|
+
click.echo(f"Results:")
|
|
92
|
+
click.echo(f" Reacted : {result.n_reacted}")
|
|
93
|
+
click.echo(f" Escaped : {result.n_escaped}")
|
|
94
|
+
click.echo(f" P(rxn) : {result.reaction_probability:.4f}")
|
|
95
|
+
D_rel = mobility.relative_translational_diffusion()
|
|
96
|
+
k = result.rate_constant(D_rel)
|
|
97
|
+
click.echo(f" k_assoc : {k:.3e} M⁻¹s⁻¹")
|
|
98
|
+
click.echo(f"{'-'*50}")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# bounding_box
|
|
102
|
+
@cli.command("bounding_box")
|
|
103
|
+
@click.argument("pqr_file")
|
|
104
|
+
@click.option("--padding", default=5.0, show_default=True, help="Padding in Å")
|
|
105
|
+
def bounding_box_cmd(pqr_file, padding):
|
|
106
|
+
"""Print bounding box of a PQR molecule."""
|
|
107
|
+
mol = parse_pqr(pqr_file)
|
|
108
|
+
bb = bounding_box(mol, padding)
|
|
109
|
+
click.echo(f"Bounding box for {pqr_file}:")
|
|
110
|
+
click.echo(f" x: [{bb.xmin:.3f}, {bb.xmax:.3f}]")
|
|
111
|
+
click.echo(f" y: [{bb.ymin:.3f}, {bb.ymax:.3f}]")
|
|
112
|
+
click.echo(f" z: [{bb.zmin:.3f}, {bb.zmax:.3f}]")
|
|
113
|
+
click.echo(f" center: {bb.center}")
|
|
114
|
+
click.echo(f" size: {bb.size}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# pqr_to_xml
|
|
118
|
+
@cli.command("pqr_to_xml")
|
|
119
|
+
@click.argument("pqr_file")
|
|
120
|
+
@click.option("--output", "-o", default=None, help="Output XML file")
|
|
121
|
+
def pqr_to_xml(pqr_file, output):
|
|
122
|
+
"""Convert a PQR file to PySTARC molecule XML format."""
|
|
123
|
+
mol = parse_pqr(pqr_file)
|
|
124
|
+
root = ET.Element("molecule", name=mol.name)
|
|
125
|
+
for a in mol.atoms:
|
|
126
|
+
ET.SubElement(
|
|
127
|
+
root,
|
|
128
|
+
"atom",
|
|
129
|
+
index=str(a.index),
|
|
130
|
+
name=a.name,
|
|
131
|
+
resname=a.residue_name,
|
|
132
|
+
resid=str(a.residue_index),
|
|
133
|
+
x=f"{a.x:.4f}",
|
|
134
|
+
y=f"{a.y:.4f}",
|
|
135
|
+
z=f"{a.z:.4f}",
|
|
136
|
+
charge=f"{a.charge:.4f}",
|
|
137
|
+
radius=f"{a.radius:.4f}",
|
|
138
|
+
)
|
|
139
|
+
tree = ET.ElementTree(root)
|
|
140
|
+
ET.indent(tree, space=" ")
|
|
141
|
+
out = output or (Path(pqr_file).stem + ".xml")
|
|
142
|
+
tree.write(out, encoding="unicode", xml_declaration=True)
|
|
143
|
+
click.echo(f"Written: {out} ({len(mol.atoms)} atoms)")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def main():
|
|
147
|
+
cli()
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
main()
|
|
File without changes
|
|
File without changes
|