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.
- molbuilder/__init__.py +1 -1
- molbuilder/cli/demos.py +73 -1
- molbuilder/cli/menu.py +2 -0
- molbuilder/dynamics/__init__.py +49 -0
- molbuilder/dynamics/forcefield.py +607 -0
- molbuilder/dynamics/integrator.py +275 -0
- molbuilder/dynamics/mechanism_choreography.py +216 -0
- molbuilder/dynamics/mechanisms.py +552 -0
- molbuilder/dynamics/simulation.py +209 -0
- molbuilder/dynamics/trajectory.py +215 -0
- molbuilder/gui/app.py +114 -0
- molbuilder/visualization/__init__.py +2 -1
- molbuilder/visualization/electron_density_viz.py +246 -0
- molbuilder/visualization/interaction_controls.py +211 -0
- molbuilder/visualization/interaction_viz.py +615 -0
- molbuilder/visualization/theme.py +7 -0
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/METADATA +1 -1
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/RECORD +22 -12
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/WHEEL +0 -0
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/entry_points.txt +0 -0
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {molbuilder-1.0.0.dist-info → molbuilder-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -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 ""
|