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,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."""