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,209 @@
|
|
|
1
|
+
"""MDSimulation orchestrator.
|
|
2
|
+
|
|
3
|
+
High-level class that wires together ForceField, VelocityVerletIntegrator,
|
|
4
|
+
and Trajectory to run plain or steered molecular dynamics from a Molecule.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from molbuilder.dynamics.forcefield import ForceField, ForceResult
|
|
14
|
+
from molbuilder.dynamics.integrator import (
|
|
15
|
+
VelocityVerletIntegrator,
|
|
16
|
+
IntegratorState,
|
|
17
|
+
BerendsenThermostat,
|
|
18
|
+
)
|
|
19
|
+
from molbuilder.dynamics.trajectory import Trajectory, TrajectoryFrame
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from molbuilder.molecule.graph import Molecule
|
|
23
|
+
from molbuilder.dynamics.mechanisms import ReactionMechanism
|
|
24
|
+
from molbuilder.dynamics.mechanism_choreography import MechanismChoreographer
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MDSimulation:
|
|
28
|
+
"""Molecular dynamics simulation orchestrator.
|
|
29
|
+
|
|
30
|
+
Builds a force field from a Molecule, initializes velocities at a
|
|
31
|
+
target temperature, and runs either plain or mechanism-steered MD,
|
|
32
|
+
recording every frame for visualization.
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
molecule : Molecule
|
|
37
|
+
Starting molecular structure.
|
|
38
|
+
dt_fs : float
|
|
39
|
+
Integration timestep in femtoseconds.
|
|
40
|
+
temperature_K : float
|
|
41
|
+
Target temperature in Kelvin.
|
|
42
|
+
thermostat : bool
|
|
43
|
+
Whether to enable Berendsen thermostat.
|
|
44
|
+
thermostat_tau_fs : float
|
|
45
|
+
Coupling time for the thermostat.
|
|
46
|
+
record_interval : int
|
|
47
|
+
Record a trajectory frame every N steps.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, molecule: Molecule,
|
|
51
|
+
dt_fs: float = 0.5,
|
|
52
|
+
temperature_K: float = 300.0,
|
|
53
|
+
thermostat: bool = True,
|
|
54
|
+
thermostat_tau_fs: float = 100.0,
|
|
55
|
+
record_interval: int = 1):
|
|
56
|
+
self.molecule = molecule
|
|
57
|
+
self.dt_fs = dt_fs
|
|
58
|
+
self.temperature_K = temperature_K
|
|
59
|
+
self.record_interval = record_interval
|
|
60
|
+
|
|
61
|
+
# Build force field
|
|
62
|
+
self.ff = ForceField.from_molecule(molecule)
|
|
63
|
+
|
|
64
|
+
# Set up thermostat
|
|
65
|
+
thermo = None
|
|
66
|
+
if thermostat:
|
|
67
|
+
thermo = BerendsenThermostat(temperature_K, thermostat_tau_fs)
|
|
68
|
+
|
|
69
|
+
# Set up integrator
|
|
70
|
+
self.integrator = VelocityVerletIntegrator(
|
|
71
|
+
dt_fs=dt_fs,
|
|
72
|
+
masses=self.ff.params.masses,
|
|
73
|
+
thermostat=thermo,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Initialize state from molecule positions
|
|
77
|
+
positions = np.array([a.position for a in molecule.atoms], dtype=float)
|
|
78
|
+
velocities = self.integrator.initialize_velocities(
|
|
79
|
+
len(molecule.atoms), temperature_K)
|
|
80
|
+
|
|
81
|
+
self.state = IntegratorState(
|
|
82
|
+
positions=positions,
|
|
83
|
+
velocities=velocities,
|
|
84
|
+
time_fs=0.0,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def run(self, n_steps: int) -> Trajectory:
|
|
88
|
+
"""Run plain molecular dynamics for n_steps.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
n_steps : int
|
|
93
|
+
Number of integration steps.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
Trajectory
|
|
98
|
+
Recorded trajectory with frames at record_interval spacing.
|
|
99
|
+
"""
|
|
100
|
+
n_atoms = len(self.molecule.atoms)
|
|
101
|
+
traj = Trajectory(n_atoms)
|
|
102
|
+
|
|
103
|
+
def force_fn(pos):
|
|
104
|
+
return self.ff.compute(pos)
|
|
105
|
+
|
|
106
|
+
# Record initial frame
|
|
107
|
+
result = force_fn(self.state.positions)
|
|
108
|
+
ke = self.integrator.kinetic_energy(self.state.velocities)
|
|
109
|
+
traj.add_frame(TrajectoryFrame(
|
|
110
|
+
time_fs=self.state.time_fs,
|
|
111
|
+
positions=self.state.positions.copy(),
|
|
112
|
+
velocities=self.state.velocities.copy(),
|
|
113
|
+
energy_kinetic=ke,
|
|
114
|
+
energy_potential=result.energy_total,
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
for step in range(1, n_steps + 1):
|
|
118
|
+
self.state = self.integrator.step(self.state, force_fn)
|
|
119
|
+
|
|
120
|
+
if step % self.record_interval == 0:
|
|
121
|
+
result = force_fn(self.state.positions)
|
|
122
|
+
ke = self.integrator.kinetic_energy(self.state.velocities)
|
|
123
|
+
traj.add_frame(TrajectoryFrame(
|
|
124
|
+
time_fs=self.state.time_fs,
|
|
125
|
+
positions=self.state.positions.copy(),
|
|
126
|
+
velocities=self.state.velocities.copy(),
|
|
127
|
+
energy_kinetic=ke,
|
|
128
|
+
energy_potential=result.energy_total,
|
|
129
|
+
))
|
|
130
|
+
|
|
131
|
+
return traj
|
|
132
|
+
|
|
133
|
+
def run_mechanism(self, mechanism: ReactionMechanism,
|
|
134
|
+
n_steps_per_stage: int = 200) -> Trajectory:
|
|
135
|
+
"""Run steered MD driven by a reaction mechanism.
|
|
136
|
+
|
|
137
|
+
The MechanismChoreographer applies time-varying restraint forces
|
|
138
|
+
to guide atoms through the mechanism stages, producing smooth
|
|
139
|
+
bond formation/breaking events.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
mechanism : ReactionMechanism
|
|
144
|
+
Reaction mechanism template.
|
|
145
|
+
n_steps_per_stage : int
|
|
146
|
+
Number of MD steps per mechanism stage.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
Trajectory
|
|
151
|
+
Recorded trajectory with fractional bond orders.
|
|
152
|
+
"""
|
|
153
|
+
from molbuilder.dynamics.mechanism_choreography import MechanismChoreographer
|
|
154
|
+
|
|
155
|
+
n_atoms = len(self.molecule.atoms)
|
|
156
|
+
traj = Trajectory(n_atoms)
|
|
157
|
+
choreographer = MechanismChoreographer(
|
|
158
|
+
mechanism, self.ff, n_steps_per_stage)
|
|
159
|
+
|
|
160
|
+
n_stages = len(mechanism.stages)
|
|
161
|
+
total_steps = n_stages * n_steps_per_stage
|
|
162
|
+
|
|
163
|
+
for stage_idx in range(n_stages):
|
|
164
|
+
for step_in_stage in range(n_steps_per_stage):
|
|
165
|
+
progress = step_in_stage / max(1, n_steps_per_stage - 1)
|
|
166
|
+
global_step = stage_idx * n_steps_per_stage + step_in_stage
|
|
167
|
+
|
|
168
|
+
def force_fn(pos, _si=stage_idx, _pr=progress):
|
|
169
|
+
ff_result = self.ff.compute(pos)
|
|
170
|
+
restraint = choreographer.restraint_forces(
|
|
171
|
+
pos, _si, _pr)
|
|
172
|
+
ff_result.forces = ff_result.forces + restraint
|
|
173
|
+
return ff_result
|
|
174
|
+
|
|
175
|
+
self.state = self.integrator.step(self.state, force_fn)
|
|
176
|
+
|
|
177
|
+
if global_step % self.record_interval == 0:
|
|
178
|
+
result = self.ff.compute(self.state.positions)
|
|
179
|
+
ke = self.integrator.kinetic_energy(self.state.velocities)
|
|
180
|
+
bond_orders = choreographer.bond_orders_at(
|
|
181
|
+
stage_idx, progress)
|
|
182
|
+
traj.add_frame(TrajectoryFrame(
|
|
183
|
+
time_fs=self.state.time_fs,
|
|
184
|
+
positions=self.state.positions.copy(),
|
|
185
|
+
velocities=self.state.velocities.copy(),
|
|
186
|
+
energy_kinetic=ke,
|
|
187
|
+
energy_potential=result.energy_total,
|
|
188
|
+
bond_orders=bond_orders,
|
|
189
|
+
))
|
|
190
|
+
|
|
191
|
+
return traj
|
|
192
|
+
|
|
193
|
+
def get_positions_at_time(self, t_fs: float,
|
|
194
|
+
trajectory: Trajectory) -> np.ndarray:
|
|
195
|
+
"""Convenience interpolation of positions from a trajectory.
|
|
196
|
+
|
|
197
|
+
Parameters
|
|
198
|
+
----------
|
|
199
|
+
t_fs : float
|
|
200
|
+
Time in femtoseconds.
|
|
201
|
+
trajectory : Trajectory
|
|
202
|
+
A previously recorded trajectory.
|
|
203
|
+
|
|
204
|
+
Returns
|
|
205
|
+
-------
|
|
206
|
+
ndarray of shape (n_atoms, 3)
|
|
207
|
+
Interpolated positions.
|
|
208
|
+
"""
|
|
209
|
+
return trajectory.at_time(t_fs)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Trajectory storage and sub-femtosecond interpolation.
|
|
2
|
+
|
|
3
|
+
Stores MD frames and provides CubicSpline interpolation for
|
|
4
|
+
requesting atomic positions at arbitrary time resolution, enabling
|
|
5
|
+
extreme slow-motion visualization of atomic interactions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
from scipy.interpolate import CubicSpline
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class TrajectoryFrame:
|
|
18
|
+
"""A single snapshot from an MD simulation.
|
|
19
|
+
|
|
20
|
+
Attributes
|
|
21
|
+
----------
|
|
22
|
+
time_fs : float
|
|
23
|
+
Simulation time in femtoseconds.
|
|
24
|
+
positions : ndarray of shape (n_atoms, 3)
|
|
25
|
+
Atomic positions in Angstroms.
|
|
26
|
+
velocities : ndarray of shape (n_atoms, 3) or None
|
|
27
|
+
Atomic velocities in A/fs (optional).
|
|
28
|
+
energy_kinetic : float
|
|
29
|
+
Kinetic energy in kJ/mol.
|
|
30
|
+
energy_potential : float
|
|
31
|
+
Potential energy in kJ/mol.
|
|
32
|
+
bond_orders : dict[tuple[int, int], float] or None
|
|
33
|
+
Fractional bond orders (for mechanism animations).
|
|
34
|
+
"""
|
|
35
|
+
time_fs: float
|
|
36
|
+
positions: np.ndarray
|
|
37
|
+
velocities: np.ndarray | None = None
|
|
38
|
+
energy_kinetic: float = 0.0
|
|
39
|
+
energy_potential: float = 0.0
|
|
40
|
+
bond_orders: dict[tuple[int, int], float] | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def energy_total(self) -> float:
|
|
44
|
+
return self.energy_kinetic + self.energy_potential
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Trajectory:
|
|
48
|
+
"""Ordered collection of MD frames with CubicSpline interpolation.
|
|
49
|
+
|
|
50
|
+
The CubicSpline interpolation is the key to sub-femtosecond slow
|
|
51
|
+
motion: MD runs at discrete timesteps (e.g. 0.5 fs), but the
|
|
52
|
+
animation pipeline can request positions at any intermediate time.
|
|
53
|
+
|
|
54
|
+
Parameters
|
|
55
|
+
----------
|
|
56
|
+
n_atoms : int
|
|
57
|
+
Number of atoms (must be consistent across all frames).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, n_atoms: int):
|
|
61
|
+
self.n_atoms = n_atoms
|
|
62
|
+
self.frames: list[TrajectoryFrame] = []
|
|
63
|
+
self._spline: CubicSpline | None = None
|
|
64
|
+
self._spline_dirty = True
|
|
65
|
+
|
|
66
|
+
def add_frame(self, frame: TrajectoryFrame):
|
|
67
|
+
"""Append a frame to the trajectory."""
|
|
68
|
+
self.frames.append(frame)
|
|
69
|
+
self._spline_dirty = True
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def n_frames(self) -> int:
|
|
73
|
+
return len(self.frames)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def times(self) -> np.ndarray:
|
|
77
|
+
"""Array of frame times in fs."""
|
|
78
|
+
return np.array([f.time_fs for f in self.frames])
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def t_start(self) -> float:
|
|
82
|
+
"""Start time of the trajectory in fs."""
|
|
83
|
+
if not self.frames:
|
|
84
|
+
return 0.0
|
|
85
|
+
return self.frames[0].time_fs
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def t_end(self) -> float:
|
|
89
|
+
"""End time of the trajectory in fs."""
|
|
90
|
+
if not self.frames:
|
|
91
|
+
return 0.0
|
|
92
|
+
return self.frames[-1].time_fs
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def duration(self) -> float:
|
|
96
|
+
"""Total duration in fs."""
|
|
97
|
+
return self.t_end - self.t_start
|
|
98
|
+
|
|
99
|
+
def _build_spline(self):
|
|
100
|
+
"""Build or rebuild the CubicSpline interpolator."""
|
|
101
|
+
if len(self.frames) < 2:
|
|
102
|
+
self._spline = None
|
|
103
|
+
self._spline_dirty = False
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
times = self.times
|
|
107
|
+
# Flatten positions to (n_frames, n_atoms*3) for spline
|
|
108
|
+
pos_flat = np.array([f.positions.ravel() for f in self.frames])
|
|
109
|
+
self._spline = CubicSpline(times, pos_flat)
|
|
110
|
+
self._spline_dirty = False
|
|
111
|
+
|
|
112
|
+
def at_time(self, t_fs: float) -> np.ndarray:
|
|
113
|
+
"""Interpolate atomic positions at an arbitrary time.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
t_fs : float
|
|
118
|
+
Time in femtoseconds. Clamped to trajectory bounds.
|
|
119
|
+
|
|
120
|
+
Returns
|
|
121
|
+
-------
|
|
122
|
+
ndarray of shape (n_atoms, 3)
|
|
123
|
+
Interpolated positions in Angstroms.
|
|
124
|
+
"""
|
|
125
|
+
if not self.frames:
|
|
126
|
+
raise ValueError("Trajectory is empty")
|
|
127
|
+
|
|
128
|
+
if len(self.frames) == 1:
|
|
129
|
+
return self.frames[0].positions.copy()
|
|
130
|
+
|
|
131
|
+
if self._spline_dirty:
|
|
132
|
+
self._build_spline()
|
|
133
|
+
|
|
134
|
+
t_fs = np.clip(t_fs, self.t_start, self.t_end)
|
|
135
|
+
flat = self._spline(t_fs)
|
|
136
|
+
return flat.reshape(self.n_atoms, 3)
|
|
137
|
+
|
|
138
|
+
def bond_order_at_time(self, t_fs: float, i: int, j: int) -> float:
|
|
139
|
+
"""Interpolate fractional bond order between atoms i and j.
|
|
140
|
+
|
|
141
|
+
Parameters
|
|
142
|
+
----------
|
|
143
|
+
t_fs : float
|
|
144
|
+
Time in femtoseconds.
|
|
145
|
+
i, j : int
|
|
146
|
+
Atom indices.
|
|
147
|
+
|
|
148
|
+
Returns
|
|
149
|
+
-------
|
|
150
|
+
float
|
|
151
|
+
Interpolated bond order (0.0 = no bond, 1.0 = single, etc.).
|
|
152
|
+
"""
|
|
153
|
+
if not self.frames:
|
|
154
|
+
return 0.0
|
|
155
|
+
|
|
156
|
+
key = (min(i, j), max(i, j))
|
|
157
|
+
times = []
|
|
158
|
+
orders = []
|
|
159
|
+
|
|
160
|
+
for frame in self.frames:
|
|
161
|
+
if frame.bond_orders is not None and key in frame.bond_orders:
|
|
162
|
+
times.append(frame.time_fs)
|
|
163
|
+
orders.append(frame.bond_orders[key])
|
|
164
|
+
|
|
165
|
+
if len(times) < 2:
|
|
166
|
+
if orders:
|
|
167
|
+
return orders[0]
|
|
168
|
+
return 0.0
|
|
169
|
+
|
|
170
|
+
t_fs = np.clip(t_fs, times[0], times[-1])
|
|
171
|
+
spline = CubicSpline(np.array(times), np.array(orders))
|
|
172
|
+
return float(np.clip(spline(t_fs), 0.0, 4.0))
|
|
173
|
+
|
|
174
|
+
def energies(self) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
175
|
+
"""Return energy arrays.
|
|
176
|
+
|
|
177
|
+
Returns
|
|
178
|
+
-------
|
|
179
|
+
times : ndarray
|
|
180
|
+
Frame times in fs.
|
|
181
|
+
ke : ndarray
|
|
182
|
+
Kinetic energies in kJ/mol.
|
|
183
|
+
pe : ndarray
|
|
184
|
+
Potential energies in kJ/mol.
|
|
185
|
+
"""
|
|
186
|
+
times = self.times
|
|
187
|
+
ke = np.array([f.energy_kinetic for f in self.frames])
|
|
188
|
+
pe = np.array([f.energy_potential for f in self.frames])
|
|
189
|
+
return times, ke, pe
|
|
190
|
+
|
|
191
|
+
def positions_at_times(self, time_array: np.ndarray) -> np.ndarray:
|
|
192
|
+
"""Interpolate positions at multiple times.
|
|
193
|
+
|
|
194
|
+
Parameters
|
|
195
|
+
----------
|
|
196
|
+
time_array : ndarray of shape (n_times,)
|
|
197
|
+
Times in fs.
|
|
198
|
+
|
|
199
|
+
Returns
|
|
200
|
+
-------
|
|
201
|
+
ndarray of shape (n_times, n_atoms, 3)
|
|
202
|
+
Interpolated positions.
|
|
203
|
+
"""
|
|
204
|
+
if self._spline_dirty:
|
|
205
|
+
self._build_spline()
|
|
206
|
+
|
|
207
|
+
if self._spline is None:
|
|
208
|
+
if self.frames:
|
|
209
|
+
pos = self.frames[0].positions
|
|
210
|
+
return np.tile(pos, (len(time_array), 1, 1))
|
|
211
|
+
raise ValueError("Trajectory is empty")
|
|
212
|
+
|
|
213
|
+
t_clamped = np.clip(time_array, self.t_start, self.t_end)
|
|
214
|
+
flat = self._spline(t_clamped) # (n_times, n_atoms*3)
|
|
215
|
+
return flat.reshape(len(time_array), self.n_atoms, 3)
|
molbuilder/gui/app.py
CHANGED
|
@@ -57,6 +57,19 @@ class MolBuilderApp:
|
|
|
57
57
|
analysis_menu.add_command(label="Bond Analysis", command=lambda: self._run_analysis("Bond Analysis"))
|
|
58
58
|
analysis_menu.add_command(label="Generate SMILES", command=lambda: self._run_analysis("SMILES"))
|
|
59
59
|
|
|
60
|
+
# Simulate menu
|
|
61
|
+
simulate_menu = tk.Menu(menubar, tearoff=0)
|
|
62
|
+
menubar.add_cascade(label="Simulate", menu=simulate_menu)
|
|
63
|
+
simulate_menu.add_command(
|
|
64
|
+
label="MD Vibration...", command=self._sim_md_vibration)
|
|
65
|
+
simulate_menu.add_command(
|
|
66
|
+
label="Bond Formation...", command=self._sim_bond_formation)
|
|
67
|
+
simulate_menu.add_command(
|
|
68
|
+
label="SN2 Mechanism...", command=self._sim_sn2)
|
|
69
|
+
simulate_menu.add_separator()
|
|
70
|
+
simulate_menu.add_command(
|
|
71
|
+
label="Export Animation...", command=self._sim_export)
|
|
72
|
+
|
|
60
73
|
def _build_ui(self):
|
|
61
74
|
# Toolbar at top
|
|
62
75
|
self.toolbar = MolToolbar(
|
|
@@ -275,6 +288,107 @@ class MolBuilderApp:
|
|
|
275
288
|
except Exception as e:
|
|
276
289
|
self.sidebar.show_results(f"Error: {e}")
|
|
277
290
|
|
|
291
|
+
# ---- Simulate menu commands ----
|
|
292
|
+
|
|
293
|
+
def _sim_md_vibration(self):
|
|
294
|
+
"""Launch MD vibration simulation on the current molecule."""
|
|
295
|
+
mol = self.handler.mol
|
|
296
|
+
if not mol or len(mol.atoms) == 0:
|
|
297
|
+
messagebox.showwarning("Simulate", "No molecule loaded.")
|
|
298
|
+
return
|
|
299
|
+
try:
|
|
300
|
+
from molbuilder.visualization.interaction_viz import (
|
|
301
|
+
visualize_md_trajectory, PlaybackConfig,
|
|
302
|
+
)
|
|
303
|
+
self.status_var.set("Running MD simulation...")
|
|
304
|
+
self.root.update()
|
|
305
|
+
config = PlaybackConfig(show_electron_density=False)
|
|
306
|
+
visualize_md_trajectory(mol, n_steps=500, config=config)
|
|
307
|
+
self.status_var.set("MD simulation complete.")
|
|
308
|
+
except Exception as e:
|
|
309
|
+
messagebox.showerror("Simulation Error", str(e))
|
|
310
|
+
|
|
311
|
+
def _sim_bond_formation(self):
|
|
312
|
+
"""Visualize bond formation between two selected atoms."""
|
|
313
|
+
mol = self.handler.mol
|
|
314
|
+
selected = list(self.handler.selected_atoms)
|
|
315
|
+
if len(selected) != 2:
|
|
316
|
+
messagebox.showinfo(
|
|
317
|
+
"Bond Formation",
|
|
318
|
+
"Select exactly 2 atoms first, then run this command.")
|
|
319
|
+
return
|
|
320
|
+
try:
|
|
321
|
+
from molbuilder.visualization.interaction_viz import (
|
|
322
|
+
visualize_bond_formation, PlaybackConfig,
|
|
323
|
+
)
|
|
324
|
+
self.status_var.set("Simulating bond formation...")
|
|
325
|
+
self.root.update()
|
|
326
|
+
config = PlaybackConfig(show_electron_density=True)
|
|
327
|
+
visualize_bond_formation(
|
|
328
|
+
mol, selected[0], selected[1], config=config)
|
|
329
|
+
self.status_var.set("Bond formation visualization complete.")
|
|
330
|
+
except Exception as e:
|
|
331
|
+
messagebox.showerror("Simulation Error", str(e))
|
|
332
|
+
|
|
333
|
+
def _sim_sn2(self):
|
|
334
|
+
"""Run an SN2 mechanism demonstration."""
|
|
335
|
+
try:
|
|
336
|
+
from molbuilder.visualization.interaction_viz import (
|
|
337
|
+
visualize_reaction, PlaybackConfig,
|
|
338
|
+
)
|
|
339
|
+
from molbuilder.dynamics.mechanisms import sn2_mechanism
|
|
340
|
+
from molbuilder.molecule.builders import build_ethane
|
|
341
|
+
|
|
342
|
+
# Build a simple substrate (methane-like with a "leaving group")
|
|
343
|
+
mol = build_ethane(60.0)
|
|
344
|
+
mechanism = sn2_mechanism(
|
|
345
|
+
substrate_C=0, nucleophile=1, leaving_group=2)
|
|
346
|
+
self.status_var.set("Running SN2 mechanism...")
|
|
347
|
+
self.root.update()
|
|
348
|
+
config = PlaybackConfig(
|
|
349
|
+
show_electron_density=True,
|
|
350
|
+
show_electron_flows=True,
|
|
351
|
+
)
|
|
352
|
+
visualize_reaction(
|
|
353
|
+
mol, mechanism,
|
|
354
|
+
n_steps_per_stage=150, config=config)
|
|
355
|
+
self.status_var.set("SN2 mechanism visualization complete.")
|
|
356
|
+
except Exception as e:
|
|
357
|
+
messagebox.showerror("Simulation Error", str(e))
|
|
358
|
+
|
|
359
|
+
def _sim_export(self):
|
|
360
|
+
"""Export the current molecule's MD animation to a file."""
|
|
361
|
+
mol = self.handler.mol
|
|
362
|
+
if not mol or len(mol.atoms) == 0:
|
|
363
|
+
messagebox.showwarning("Export", "No molecule loaded.")
|
|
364
|
+
return
|
|
365
|
+
from tkinter import filedialog
|
|
366
|
+
path = filedialog.asksaveasfilename(
|
|
367
|
+
parent=self.root,
|
|
368
|
+
title="Export Animation",
|
|
369
|
+
filetypes=[("GIF", "*.gif"), ("MP4", "*.mp4")],
|
|
370
|
+
defaultextension=".gif",
|
|
371
|
+
)
|
|
372
|
+
if not path:
|
|
373
|
+
return
|
|
374
|
+
try:
|
|
375
|
+
from molbuilder.visualization.interaction_viz import (
|
|
376
|
+
visualize_md_trajectory, PlaybackConfig,
|
|
377
|
+
)
|
|
378
|
+
self.status_var.set(f"Exporting animation to {path}...")
|
|
379
|
+
self.root.update()
|
|
380
|
+
config = PlaybackConfig(
|
|
381
|
+
export_path=path,
|
|
382
|
+
show_electron_density=False,
|
|
383
|
+
)
|
|
384
|
+
viz = visualize_md_trajectory(
|
|
385
|
+
mol, n_steps=300, config=config, show=False)
|
|
386
|
+
viz.export(path)
|
|
387
|
+
self.status_var.set(f"Exported: {path}")
|
|
388
|
+
messagebox.showinfo("Export", f"Animation saved to:\n{path}")
|
|
389
|
+
except Exception as e:
|
|
390
|
+
messagebox.showerror("Export Error", str(e))
|
|
391
|
+
|
|
278
392
|
def run(self):
|
|
279
393
|
"""Start the application main loop."""
|
|
280
394
|
self.root.mainloop()
|
|
@@ -1 +1,2 @@
|
|
|
1
|
-
"""Visualization: Bohr models, orbital clouds, molecule rendering
|
|
1
|
+
"""Visualization: Bohr models, orbital clouds, molecule rendering,
|
|
2
|
+
and extreme slow-motion atomic interaction animations."""
|