molbuilder 1.0.0__py3-none-any.whl → 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.
@@ -0,0 +1,275 @@
1
+ """Velocity Verlet integrator and Berendsen thermostat.
2
+
3
+ Scientific basis:
4
+ Swope et al., J. Chem. Phys. 1982, 76, 637.
5
+
6
+ The Velocity Verlet algorithm is symplectic, time-reversible, and
7
+ second-order accurate:
8
+
9
+ v(t + dt/2) = v(t) + (dt/2) * a(t)
10
+ r(t + dt) = r(t) + dt * v(t + dt/2)
11
+ a(t + dt) = F(r(t+dt)) / m
12
+ v(t + dt) = v(t + dt/2) + (dt/2) * a(t + dt)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from dataclasses import dataclass
19
+
20
+ import numpy as np
21
+
22
+
23
+ # ===================================================================
24
+ # Unit conversion constants
25
+ # ===================================================================
26
+
27
+ # 1 fs = 1e-15 s; positions in Angstroms, masses in AMU, energies in kJ/mol
28
+ # Conversion factor: F [kJ/(mol*A)] -> a [A/fs^2]
29
+ # a = F / m with F in kJ/(mol*A), m in AMU
30
+ # 1 kJ/mol = 1e3 J/mol = 1e3 / (6.022e23) J = 1.6605e-21 J per particle
31
+ # 1 AMU = 1.6605e-27 kg
32
+ # a = F * 1.6605e-21 / (m * 1.6605e-27) [m/s^2] -- but we want A/fs^2
33
+ # a = F / m * 1e-21/1e-27 * 1e-10/1e-30 ... simplifying:
34
+ # a [A/fs^2] = F [kJ/(mol*A)] / m [AMU] * (1e-2)
35
+ # More precisely: kJ/(mol*A*AMU) -> A/fs^2 = 1/(Avogadro * 1e-26) -> 1/6.022e-3
36
+ # = 1e3 / (6.022e23 * 1e-27 * 1e10) -- let's use the correct factor
37
+ # F_SI = F_kJ_mol_A * 1e3 / (6.022e23) / 1e-10 [N per particle]
38
+ # a_SI = F_SI / (m_AMU * 1.6605e-27) [m/s^2]
39
+ # a_A_fs2 = a_SI * 1e-10 * 1e-30 ... no, A/fs^2 = (1e-10 m)/(1e-15 s)^2 = 1e20 m/s^2
40
+ # So a [A/fs^2] = a_SI / 1e20
41
+ # = F_kJ_mol_A * 1e13 / (6.022e23 * m_AMU * 1.6605e-27 * 1e20)
42
+ # Let's compute numerically:
43
+ # factor = 1e3 / (6.02214076e23 * 1e-10) / (1.66053906660e-27 * 1e20)
44
+ # = 1e3 / (6.02214076e13) / (1.66053906660e-7)
45
+ # = 1e3 * 1e7 / (6.02214076e13 * 1.66053906660)
46
+ # = 1e10 / (6.02214076e13 * 1.66053906660)
47
+ # = 1e10 / (1.00000e14) (approximately)
48
+ # = 1e-4
49
+ # More precisely: 1e3 / (6.02214076e23) = 1.66054e-21 J/(A)
50
+ # / (1.66054e-27 kg) = 1e6 m/s^2 per (A)
51
+ # * 1e-10 m/A = 1e-4 m^2/(A*s^2) ... let me just compute directly
52
+ # CONVERSION_FACTOR = 1e-4 approximately
53
+ # Use the precise value: 1 / (AMU * Avogadro) in appropriate units
54
+ _KJ_MOL_A_TO_A_FS2 = 1.0e-4 # kJ/(mol*A*AMU) -> A/fs^2 (approximate)
55
+
56
+
57
+ @dataclass
58
+ class IntegratorState:
59
+ """State of the MD system at a single time point.
60
+
61
+ Attributes
62
+ ----------
63
+ positions : ndarray of shape (n_atoms, 3)
64
+ Atomic positions in Angstroms.
65
+ velocities : ndarray of shape (n_atoms, 3)
66
+ Atomic velocities in A/fs.
67
+ time_fs : float
68
+ Current simulation time in femtoseconds.
69
+ """
70
+ positions: np.ndarray
71
+ velocities: np.ndarray
72
+ time_fs: float = 0.0
73
+
74
+
75
+ class BerendsenThermostat:
76
+ """Berendsen weak-coupling thermostat.
77
+
78
+ Rescales velocities to weakly couple the system to a heat bath
79
+ at a target temperature. The coupling time tau controls how
80
+ strongly the system is coupled (larger tau = weaker coupling).
81
+
82
+ Parameters
83
+ ----------
84
+ target_T : float
85
+ Target temperature in Kelvin.
86
+ tau_fs : float
87
+ Coupling time constant in femtoseconds. Typical: 100 fs.
88
+ """
89
+
90
+ def __init__(self, target_T: float = 300.0, tau_fs: float = 100.0):
91
+ self.target_T = target_T
92
+ self.tau_fs = tau_fs
93
+
94
+ def rescale(self, velocities: np.ndarray, masses: np.ndarray,
95
+ dt_fs: float) -> np.ndarray:
96
+ """Apply Berendsen velocity rescaling.
97
+
98
+ Parameters
99
+ ----------
100
+ velocities : ndarray of shape (n_atoms, 3)
101
+ Current velocities in A/fs.
102
+ masses : ndarray of shape (n_atoms,)
103
+ Atomic masses in AMU.
104
+ dt_fs : float
105
+ Integration timestep in fs.
106
+
107
+ Returns
108
+ -------
109
+ ndarray of shape (n_atoms, 3)
110
+ Rescaled velocities.
111
+ """
112
+ current_T = self._temperature(velocities, masses)
113
+ if current_T < 1e-10:
114
+ return velocities
115
+
116
+ lam2 = 1.0 + (dt_fs / self.tau_fs) * (self.target_T / current_T - 1.0)
117
+ if lam2 < 0:
118
+ lam2 = 0.0
119
+ lam = math.sqrt(lam2)
120
+ return velocities * lam
121
+
122
+ @staticmethod
123
+ def _temperature(velocities: np.ndarray, masses: np.ndarray) -> float:
124
+ """Compute instantaneous kinetic temperature in Kelvin.
125
+
126
+ T = (2 * KE) / (n_dof * k_B)
127
+ where n_dof = 3*N - 3 (removing center-of-mass translation).
128
+
129
+ Uses internal units: KE in AMU * A^2 / fs^2, k_B in same units.
130
+ k_B = 8.3145e-3 kJ/(mol*K), and 1 AMU*A^2/fs^2 = 1e-4 * 1 kJ/mol
131
+ => k_B in AMU*A^2/(fs^2*K) = 8.3145e-3 / 1e-4 * ... no, let's be
132
+ precise.
133
+
134
+ KE [AMU*A^2/fs^2] = sum 0.5 * m_i * v_i^2
135
+ To convert to kJ/mol: multiply by 1e4 (inverse of _KJ_MOL_A_TO_A_FS2 * mass)
136
+ Actually: E [kJ/mol] = 0.5 * m [AMU] * v^2 [A^2/fs^2] / _KJ_MOL_A_TO_A_FS2
137
+ Hmm, let's simplify with k_B in internal units.
138
+ """
139
+ n_atoms = len(masses)
140
+ if n_atoms < 2:
141
+ return 0.0
142
+ n_dof = 3 * n_atoms - 3
143
+
144
+ # KE in kJ/mol: 0.5 * m * v^2 / conversion
145
+ # v [A/fs], m [AMU]: KE_amu = 0.5 * m * v^2 [AMU*A^2/fs^2]
146
+ # To kJ/mol: KE_kJ = KE_amu / _KJ_MOL_A_TO_A_FS2
147
+ ke_per_atom = 0.5 * masses[:, None] * velocities ** 2
148
+ ke_total = np.sum(ke_per_atom) / _KJ_MOL_A_TO_A_FS2 # kJ/mol
149
+
150
+ # k_B = 8.3145e-3 kJ/(mol*K)
151
+ k_B = 8.3145e-3
152
+ T = 2.0 * ke_total / (n_dof * k_B)
153
+ return T
154
+
155
+
156
+ class VelocityVerletIntegrator:
157
+ """Velocity Verlet integrator for molecular dynamics.
158
+
159
+ Parameters
160
+ ----------
161
+ dt_fs : float
162
+ Timestep in femtoseconds. Default 0.5 fs is suitable for
163
+ all-atom simulations with hydrogens.
164
+ masses : ndarray of shape (n_atoms,)
165
+ Atomic masses in AMU.
166
+ thermostat : BerendsenThermostat or None
167
+ Optional thermostat for temperature control.
168
+ """
169
+
170
+ def __init__(self, dt_fs: float, masses: np.ndarray,
171
+ thermostat: BerendsenThermostat | None = None):
172
+ self.dt = dt_fs
173
+ self.masses = masses
174
+ self.thermostat = thermostat
175
+
176
+ def step(self, state: IntegratorState,
177
+ force_fn) -> IntegratorState:
178
+ """Perform one Velocity Verlet integration step.
179
+
180
+ Parameters
181
+ ----------
182
+ state : IntegratorState
183
+ Current positions, velocities, and time.
184
+ force_fn : callable
185
+ ``force_fn(positions) -> ForceResult`` returning forces in
186
+ kJ/(mol*A).
187
+
188
+ Returns
189
+ -------
190
+ IntegratorState
191
+ Updated state after one timestep.
192
+ """
193
+ dt = self.dt
194
+ m = self.masses[:, None] # (n_atoms, 1)
195
+
196
+ # Current acceleration: a = F/m converted to A/fs^2
197
+ result = force_fn(state.positions)
198
+ accel = result.forces / m * _KJ_MOL_A_TO_A_FS2
199
+
200
+ # Half-step velocity
201
+ v_half = state.velocities + 0.5 * dt * accel
202
+
203
+ # Full-step position
204
+ new_pos = state.positions + dt * v_half
205
+
206
+ # New acceleration
207
+ result_new = force_fn(new_pos)
208
+ accel_new = result_new.forces / m * _KJ_MOL_A_TO_A_FS2
209
+
210
+ # Full-step velocity
211
+ new_vel = v_half + 0.5 * dt * accel_new
212
+
213
+ # Apply thermostat
214
+ if self.thermostat is not None:
215
+ new_vel = self.thermostat.rescale(new_vel, self.masses, dt)
216
+
217
+ return IntegratorState(
218
+ positions=new_pos,
219
+ velocities=new_vel,
220
+ time_fs=state.time_fs + dt,
221
+ )
222
+
223
+ def initialize_velocities(self, n_atoms: int,
224
+ temperature_K: float) -> np.ndarray:
225
+ """Generate Maxwell-Boltzmann distributed velocities.
226
+
227
+ Parameters
228
+ ----------
229
+ n_atoms : int
230
+ Number of atoms.
231
+ temperature_K : float
232
+ Target temperature in Kelvin.
233
+
234
+ Returns
235
+ -------
236
+ ndarray of shape (n_atoms, 3)
237
+ Velocities in A/fs.
238
+ """
239
+ if temperature_K < 1e-10:
240
+ return np.zeros((n_atoms, 3))
241
+
242
+ # k_B T in kJ/mol
243
+ k_B = 8.3145e-3 # kJ/(mol*K)
244
+ kT = k_B * temperature_K
245
+
246
+ velocities = np.zeros((n_atoms, 3))
247
+ for i in range(n_atoms):
248
+ # sigma_v [A/fs] from equipartition: 0.5 * m * v^2 = 0.5 * kT
249
+ # v_rms = sqrt(kT/m) but in our units:
250
+ # kT [kJ/mol], m [AMU] -> sigma [A/fs]
251
+ sigma = math.sqrt(kT * _KJ_MOL_A_TO_A_FS2 / self.masses[i])
252
+ velocities[i] = np.random.normal(0.0, sigma, 3)
253
+
254
+ # Remove center-of-mass velocity
255
+ total_mass = np.sum(self.masses)
256
+ com_vel = np.sum(self.masses[:, None] * velocities, axis=0) / total_mass
257
+ velocities -= com_vel[None, :]
258
+
259
+ return velocities
260
+
261
+ def kinetic_energy(self, velocities: np.ndarray) -> float:
262
+ """Compute kinetic energy in kJ/mol.
263
+
264
+ Parameters
265
+ ----------
266
+ velocities : ndarray of shape (n_atoms, 3)
267
+ Velocities in A/fs.
268
+
269
+ Returns
270
+ -------
271
+ float
272
+ Kinetic energy in kJ/mol.
273
+ """
274
+ ke_internal = 0.5 * np.sum(self.masses[:, None] * velocities ** 2)
275
+ return ke_internal / _KJ_MOL_A_TO_A_FS2
@@ -0,0 +1,216 @@
1
+ """Steered-MD choreography for reaction mechanisms.
2
+
3
+ Converts MechanismStage targets (distance, bond order, angle) into
4
+ time-varying harmonic restraint forces that guide the MD simulation
5
+ smoothly through each stage of the mechanism. A sigmoid ramp function
6
+ ensures gradual transitions.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from typing import TYPE_CHECKING
13
+
14
+ import numpy as np
15
+
16
+ from molbuilder.dynamics.mechanisms import (
17
+ ReactionMechanism,
18
+ MechanismStage,
19
+ ElectronFlow,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from molbuilder.dynamics.forcefield import ForceField
24
+
25
+
26
+ def _sigmoid(x: float) -> float:
27
+ """Smooth sigmoid ramp: 0 at x=0, ~1 at x=1."""
28
+ t = np.clip(x, 0.0, 1.0)
29
+ return float(1.0 / (1.0 + np.exp(-10.0 * (t - 0.5))))
30
+
31
+
32
+ class MechanismChoreographer:
33
+ """Converts mechanism stages into restraint forces for steered MD.
34
+
35
+ For each stage, harmonic distance restraints with a sigmoid ramp
36
+ are applied to guide atoms toward their target distances, producing
37
+ smooth bond formation and breaking events.
38
+
39
+ Parameters
40
+ ----------
41
+ mechanism : ReactionMechanism
42
+ The reaction mechanism template.
43
+ forcefield : ForceField
44
+ The underlying force field (used for reference).
45
+ n_steps_per_stage : int
46
+ Number of MD steps per mechanism stage.
47
+ restraint_k : float
48
+ Restraint force constant in kJ/(mol*A^2). Higher values
49
+ make the steering stronger.
50
+ """
51
+
52
+ def __init__(self, mechanism: ReactionMechanism,
53
+ forcefield: ForceField,
54
+ n_steps_per_stage: int = 200,
55
+ restraint_k: float = 500.0):
56
+ self.mechanism = mechanism
57
+ self.ff = forcefield
58
+ self.n_steps_per_stage = n_steps_per_stage
59
+ self.restraint_k = restraint_k
60
+
61
+ # Precompute initial distances for each target
62
+ self._initial_distances: dict[int, dict[tuple[int, int], float]] = {}
63
+
64
+ def _get_initial_distance(self, positions: np.ndarray,
65
+ i: int, j: int) -> float:
66
+ """Compute distance between atoms i and j."""
67
+ return float(np.linalg.norm(positions[j] - positions[i]))
68
+
69
+ def restraint_forces(self, positions: np.ndarray,
70
+ stage_idx: int,
71
+ progress: float) -> np.ndarray:
72
+ """Compute restraint forces for the current stage and progress.
73
+
74
+ Parameters
75
+ ----------
76
+ positions : ndarray of shape (n_atoms, 3)
77
+ Current atomic positions.
78
+ stage_idx : int
79
+ Index of the current mechanism stage.
80
+ progress : float
81
+ Progress through the current stage (0.0 to 1.0).
82
+
83
+ Returns
84
+ -------
85
+ ndarray of shape (n_atoms, 3)
86
+ Restraint forces in kJ/(mol*A).
87
+ """
88
+ forces = np.zeros_like(positions)
89
+ stage = self.mechanism.stages[stage_idx]
90
+ ramp = _sigmoid(progress)
91
+
92
+ # Distance restraints
93
+ for (i, j), target_dist in stage.distance_targets.items():
94
+ rij = positions[j] - positions[i]
95
+ r = np.linalg.norm(rij)
96
+ if r < 1e-12:
97
+ continue
98
+
99
+ # Interpolate target distance with sigmoid ramp
100
+ current_target = target_dist # could interpolate from previous
101
+ dr = r - current_target
102
+ f_mag = -self.restraint_k * ramp * dr
103
+ f_vec = f_mag * (rij / r)
104
+
105
+ forces[i] -= f_vec
106
+ forces[j] += f_vec
107
+
108
+ # Angle restraints
109
+ for (i, j, k), target_angle_deg in stage.angle_targets.items():
110
+ rji = positions[i] - positions[j]
111
+ rjk = positions[k] - positions[j]
112
+ nji = np.linalg.norm(rji)
113
+ njk = np.linalg.norm(rjk)
114
+ if nji < 1e-12 or njk < 1e-12:
115
+ continue
116
+
117
+ cos_theta = np.clip(
118
+ np.dot(rji, rjk) / (nji * njk), -1.0, 1.0)
119
+ theta = math.acos(cos_theta)
120
+ target_rad = math.radians(target_angle_deg)
121
+ d_theta = theta - target_rad
122
+
123
+ sin_theta = math.sin(theta)
124
+ if abs(sin_theta) < 1e-12:
125
+ continue
126
+
127
+ # Angle restraint force constant (kJ/(mol*rad^2))
128
+ angle_k = self.restraint_k * 0.5 * ramp
129
+ dE_dtheta = angle_k * d_theta
130
+
131
+ rji_hat = rji / nji
132
+ rjk_hat = rjk / njk
133
+ fi = (dE_dtheta / (nji * sin_theta)) * (
134
+ cos_theta * rji_hat - rjk_hat)
135
+ fk = (dE_dtheta / (njk * sin_theta)) * (
136
+ cos_theta * rjk_hat - rji_hat)
137
+
138
+ forces[i] -= fi
139
+ forces[k] -= fk
140
+ forces[j] += fi + fk
141
+
142
+ return forces
143
+
144
+ def bond_orders_at(self, stage_idx: int,
145
+ progress: float) -> dict[tuple[int, int], float]:
146
+ """Compute interpolated bond orders at the current stage/progress.
147
+
148
+ Parameters
149
+ ----------
150
+ stage_idx : int
151
+ Index of the current mechanism stage.
152
+ progress : float
153
+ Progress through the current stage (0.0 to 1.0).
154
+
155
+ Returns
156
+ -------
157
+ dict[tuple[int, int], float]
158
+ Bond order for each atom pair that has a target.
159
+ """
160
+ stage = self.mechanism.stages[stage_idx]
161
+ ramp = _sigmoid(progress)
162
+
163
+ # Get previous stage bond orders as starting point
164
+ prev_orders: dict[tuple[int, int], float] = {}
165
+ if stage_idx > 0:
166
+ prev_stage = self.mechanism.stages[stage_idx - 1]
167
+ prev_orders = dict(prev_stage.bond_order_changes)
168
+
169
+ result: dict[tuple[int, int], float] = {}
170
+ for key, target_order in stage.bond_order_changes.items():
171
+ normalized_key = (min(key), max(key))
172
+ prev = prev_orders.get(key, 0.0)
173
+ result[normalized_key] = prev + ramp * (target_order - prev)
174
+
175
+ return result
176
+
177
+ def electron_flows_at(self, stage_idx: int,
178
+ progress: float) -> list[ElectronFlow]:
179
+ """Return the electron flows active at the current stage/progress.
180
+
181
+ Flows are returned when progress is between 0.1 and 0.9 to
182
+ avoid showing arrows at the very start/end of a stage.
183
+
184
+ Parameters
185
+ ----------
186
+ stage_idx : int
187
+ Index of the current mechanism stage.
188
+ progress : float
189
+ Progress through the current stage.
190
+
191
+ Returns
192
+ -------
193
+ list[ElectronFlow]
194
+ Active electron flow arrows.
195
+ """
196
+ if progress < 0.1 or progress > 0.9:
197
+ return []
198
+ stage = self.mechanism.stages[stage_idx]
199
+ return list(stage.electron_flows)
200
+
201
+ def stage_annotation(self, stage_idx: int) -> str:
202
+ """Return the text annotation for the current stage.
203
+
204
+ Parameters
205
+ ----------
206
+ stage_idx : int
207
+ Index of the current mechanism stage.
208
+
209
+ Returns
210
+ -------
211
+ str
212
+ Annotation text.
213
+ """
214
+ if 0 <= stage_idx < len(self.mechanism.stages):
215
+ return self.mechanism.stages[stage_idx].annotation
216
+ return ""