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,246 @@
1
+ """Electron cloud rendering for bond events.
2
+
3
+ Uses an LCAO (Linear Combination of Atomic Orbitals) approximation to
4
+ visualize electron density during bond formation and breaking. The
5
+ bonding orbital is modelled as:
6
+
7
+ psi_bond = c_a * phi_a(r - R_a) + c_b * phi_b(r - R_b)
8
+
9
+ where phi are hydrogen-like orbitals evaluated with Slater effective
10
+ nuclear charges, and c_a, c_b are mixing coefficients determined by
11
+ the bond order.
12
+
13
+ Reuses radial_wavefunction, real_spherical_harmonic, and
14
+ wavefunction_real from molbuilder.atomic.wavefunctions.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import math
20
+
21
+ import numpy as np
22
+
23
+ from molbuilder.core.constants import BOHR_RADIUS_M
24
+ from molbuilder.core.elements import SYMBOL_TO_Z
25
+ from molbuilder.visualization.theme import ELECTRON_CLOUD_COLOR
26
+
27
+
28
+ # Bohr radius in Angstroms
29
+ _A0_ANGSTROM = BOHR_RADIUS_M * 1e10 # ~0.529 A
30
+
31
+
32
+ def _hex_to_rgba(hex_color: str, alpha: float = 0.3) -> tuple:
33
+ """Convert hex color string to RGBA tuple (0-1 scale)."""
34
+ h = hex_color.lstrip("#")
35
+ r, g, b = (int(h[i:i+2], 16) / 255.0 for i in (0, 2, 4))
36
+ return (r, g, b, alpha)
37
+
38
+
39
+ def _slater_zeff_simple(Z: int) -> float:
40
+ """Simplified Slater Z_eff for outermost valence s/p orbital.
41
+
42
+ Uses a rough approximation: Z_eff ~ Z - S where S is the total
43
+ screening. For quick rendering this is sufficient.
44
+ """
45
+ # Very simplified: use known values for common elements
46
+ _ZEFF = {
47
+ 1: 1.0, 6: 3.25, 7: 3.9, 8: 4.55, 9: 5.2,
48
+ 15: 4.8, 16: 5.45, 17: 6.1, 35: 9.0, 53: 12.0,
49
+ }
50
+ return _ZEFF.get(Z, max(1.0, Z * 0.3))
51
+
52
+
53
+ def _valence_nl(Z: int) -> tuple[int, int]:
54
+ """Return (n, l) for the outermost valence orbital."""
55
+ if Z <= 2:
56
+ return (1, 0)
57
+ if Z <= 10:
58
+ if Z <= 4:
59
+ return (2, 0)
60
+ return (2, 1)
61
+ if Z <= 18:
62
+ if Z <= 12:
63
+ return (3, 0)
64
+ return (3, 1)
65
+ if Z <= 36:
66
+ return (4, 0)
67
+ return (5, 0)
68
+
69
+
70
+ class ElectronDensityRenderer:
71
+ """Renders electron density clouds using Monte Carlo point sampling.
72
+
73
+ Generates scatter points weighted by the LCAO bonding orbital
74
+ probability density, producing a visual representation of the
75
+ electron cloud during bond events.
76
+
77
+ Parameters
78
+ ----------
79
+ n_points : int
80
+ Number of scatter points to generate per bond. More points
81
+ give smoother clouds but slower rendering.
82
+ """
83
+
84
+ def __init__(self, n_points: int = 5000):
85
+ self.n_points = n_points
86
+ self._rng = np.random.default_rng(42)
87
+
88
+ def compute_bond_density(self, pos_a: np.ndarray, pos_b: np.ndarray,
89
+ z_a: int, z_b: int,
90
+ bond_order: float = 1.0,
91
+ ) -> tuple[np.ndarray, np.ndarray]:
92
+ """Compute electron cloud scatter points for a bond.
93
+
94
+ Uses rejection sampling: generates random points in the bonding
95
+ region and accepts them with probability proportional to
96
+ |psi_bond|^2.
97
+
98
+ Parameters
99
+ ----------
100
+ pos_a, pos_b : ndarray of shape (3,)
101
+ Atomic positions in Angstroms.
102
+ z_a, z_b : int
103
+ Atomic numbers.
104
+ bond_order : float
105
+ Fractional bond order (0 to 3). Controls cloud density.
106
+
107
+ Returns
108
+ -------
109
+ points : ndarray of shape (n_accepted, 3)
110
+ Accepted scatter point positions.
111
+ colors : ndarray of shape (n_accepted, 4)
112
+ RGBA colors for each point.
113
+ """
114
+ if bond_order < 0.05:
115
+ return np.empty((0, 3)), np.empty((0, 4))
116
+
117
+ midpoint = 0.5 * (pos_a + pos_b)
118
+ bond_vec = pos_b - pos_a
119
+ bond_len = np.linalg.norm(bond_vec)
120
+ if bond_len < 1e-6:
121
+ return np.empty((0, 3)), np.empty((0, 4))
122
+
123
+ # Effective nuclear charges
124
+ zeff_a = _slater_zeff_simple(z_a)
125
+ zeff_b = _slater_zeff_simple(z_b)
126
+
127
+ # Valence orbital quantum numbers
128
+ n_a, l_a = _valence_nl(z_a)
129
+ n_b, l_b = _valence_nl(z_b)
130
+
131
+ # Sampling region: ellipsoid around the bond
132
+ spread = max(bond_len * 0.8, 1.5)
133
+
134
+ # Generate candidate points
135
+ pts = self._rng.normal(0, spread * 0.4, (self.n_points, 3))
136
+ pts += midpoint
137
+
138
+ # Evaluate approximate orbital values at each point
139
+ # Use simplified 1s-like orbitals for speed
140
+ r_a = np.linalg.norm(pts - pos_a, axis=1)
141
+ r_b = np.linalg.norm(pts - pos_b, axis=1)
142
+
143
+ # Slater-type orbital: phi ~ r^(n-1) * exp(-zeff * r / (n * a0))
144
+ decay_a = zeff_a / (n_a * _A0_ANGSTROM)
145
+ decay_b = zeff_b / (n_b * _A0_ANGSTROM)
146
+
147
+ phi_a = np.exp(-decay_a * r_a)
148
+ phi_b = np.exp(-decay_b * r_b)
149
+
150
+ # LCAO bonding combination
151
+ c_a = 1.0 / math.sqrt(2.0)
152
+ c_b = 1.0 / math.sqrt(2.0)
153
+ psi_bond = c_a * phi_a + c_b * phi_b
154
+ density = psi_bond ** 2
155
+
156
+ # Normalize and apply bond order scaling
157
+ max_dens = np.max(density)
158
+ if max_dens < 1e-30:
159
+ return np.empty((0, 3)), np.empty((0, 4))
160
+ prob = density / max_dens * min(bond_order, 1.5)
161
+
162
+ # Rejection sampling
163
+ rng_vals = self._rng.uniform(0, 1, self.n_points)
164
+ accept = rng_vals < prob
165
+
166
+ accepted_pts = pts[accept]
167
+ n_accepted = len(accepted_pts)
168
+
169
+ if n_accepted == 0:
170
+ return np.empty((0, 3)), np.empty((0, 4))
171
+
172
+ # Colors: base color with alpha proportional to density
173
+ base_rgba = _hex_to_rgba(ELECTRON_CLOUD_COLOR, 0.4)
174
+ colors = np.tile(base_rgba, (n_accepted, 1))
175
+ # Modulate alpha by density
176
+ accepted_density = density[accept]
177
+ alpha_scale = accepted_density / max_dens
178
+ colors[:, 3] = 0.1 + 0.4 * alpha_scale * min(bond_order, 1.5)
179
+
180
+ return accepted_pts, colors
181
+
182
+ def render_on_axis(self, ax, pos_a: np.ndarray, pos_b: np.ndarray,
183
+ z_a: int, z_b: int,
184
+ bond_order: float = 1.0,
185
+ point_size: float = 2.0):
186
+ """Draw electron density cloud on a matplotlib 3D axis.
187
+
188
+ Parameters
189
+ ----------
190
+ ax : Axes3D
191
+ Matplotlib 3D axis to draw on.
192
+ pos_a, pos_b : ndarray of shape (3,)
193
+ Atomic positions in Angstroms.
194
+ z_a, z_b : int
195
+ Atomic numbers.
196
+ bond_order : float
197
+ Fractional bond order.
198
+ point_size : float
199
+ Size of scatter points.
200
+ """
201
+ points, colors = self.compute_bond_density(
202
+ pos_a, pos_b, z_a, z_b, bond_order)
203
+ if len(points) == 0:
204
+ return
205
+
206
+ ax.scatter(
207
+ points[:, 0], points[:, 1], points[:, 2],
208
+ c=colors,
209
+ s=point_size,
210
+ alpha=0.3,
211
+ depthshade=True,
212
+ edgecolors="none",
213
+ )
214
+
215
+ def render_on_axis_2d(self, ax, pos_a: np.ndarray, pos_b: np.ndarray,
216
+ z_a: int, z_b: int,
217
+ bond_order: float = 1.0,
218
+ point_size: float = 2.0):
219
+ """Draw electron density cloud on a 2D matplotlib axis.
220
+
221
+ Uses the first two coordinates (x, y) for 2D projection.
222
+
223
+ Parameters
224
+ ----------
225
+ ax : Axes
226
+ Matplotlib 2D axis.
227
+ pos_a, pos_b : ndarray
228
+ Atomic positions.
229
+ z_a, z_b : int
230
+ Atomic numbers.
231
+ bond_order : float
232
+ Fractional bond order.
233
+ point_size : float
234
+ Scatter point size.
235
+ """
236
+ points, colors = self.compute_bond_density(
237
+ pos_a, pos_b, z_a, z_b, bond_order)
238
+ if len(points) == 0:
239
+ return
240
+
241
+ ax.scatter(
242
+ points[:, 0], points[:, 1],
243
+ c=colors,
244
+ s=point_size,
245
+ edgecolors="none",
246
+ )
@@ -0,0 +1,211 @@
1
+ """Playback controls for the interaction visualizer.
2
+
3
+ Provides keyboard bindings for interactive animation control and an
4
+ optional tkinter panel for GUI embedding.
5
+
6
+ Keyboard bindings:
7
+ Space : Play / Pause
8
+ Right : Step forward
9
+ Left : Step backward
10
+ Up : Increase speed (2x)
11
+ Down : Decrease speed (0.5x)
12
+ E : Toggle electron density
13
+ L : Toggle labels
14
+ R : Reset to beginning
15
+ Q / Esc : Close
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ if TYPE_CHECKING:
23
+ from molbuilder.visualization.interaction_viz import InteractionVisualizer
24
+
25
+
26
+ class PlaybackController:
27
+ """Keyboard-driven playback controller for InteractionVisualizer.
28
+
29
+ Connects to matplotlib key_press_event to provide interactive
30
+ playback controls.
31
+
32
+ Parameters
33
+ ----------
34
+ visualizer : InteractionVisualizer
35
+ The visualizer to control.
36
+ """
37
+
38
+ def __init__(self, visualizer: InteractionVisualizer):
39
+ self.viz = visualizer
40
+ self._speed_multiplier = 1.0
41
+ self._current_frame = 0
42
+ self._connected = False
43
+
44
+ def connect(self):
45
+ """Connect keyboard event handlers to the visualizer's figure."""
46
+ if self.viz.fig is None:
47
+ return
48
+ self.viz.fig.canvas.mpl_connect("key_press_event", self._on_key)
49
+ self._connected = True
50
+
51
+ def _on_key(self, event):
52
+ """Handle a key press event."""
53
+ if event.key == " ":
54
+ self._toggle_pause()
55
+ elif event.key == "right":
56
+ self._step_forward()
57
+ elif event.key == "left":
58
+ self._step_backward()
59
+ elif event.key == "up":
60
+ self._speed_up()
61
+ elif event.key == "down":
62
+ self._slow_down()
63
+ elif event.key == "e":
64
+ self._toggle_electron_density()
65
+ elif event.key == "l":
66
+ self._toggle_labels()
67
+ elif event.key == "r":
68
+ self._reset()
69
+ elif event.key in ("q", "escape"):
70
+ self._close()
71
+
72
+ def _toggle_pause(self):
73
+ """Toggle play/pause."""
74
+ anim = self.viz._anim
75
+ if anim is None:
76
+ return
77
+ if self.viz._paused:
78
+ anim.resume()
79
+ self.viz._paused = False
80
+ else:
81
+ anim.pause()
82
+ self.viz._paused = True
83
+
84
+ def _step_forward(self):
85
+ """Step one frame forward (pauses first)."""
86
+ anim = self.viz._anim
87
+ if anim is None:
88
+ return
89
+ if not self.viz._paused:
90
+ anim.pause()
91
+ self.viz._paused = True
92
+ self._current_frame = min(
93
+ self._current_frame + 1, self.viz.n_frames - 1)
94
+ self.viz._render_frame(self._current_frame)
95
+ self.viz.fig.canvas.draw_idle()
96
+
97
+ def _step_backward(self):
98
+ """Step one frame backward."""
99
+ anim = self.viz._anim
100
+ if anim is None:
101
+ return
102
+ if not self.viz._paused:
103
+ anim.pause()
104
+ self.viz._paused = True
105
+ self._current_frame = max(self._current_frame - 1, 0)
106
+ self.viz._render_frame(self._current_frame)
107
+ self.viz.fig.canvas.draw_idle()
108
+
109
+ def _speed_up(self):
110
+ """Double the playback speed."""
111
+ self._speed_multiplier *= 2.0
112
+ anim = self.viz._anim
113
+ if anim is not None:
114
+ new_interval = max(
115
+ 1, int(1000 / (self.viz.config.fps * self._speed_multiplier)))
116
+ anim.event_source.interval = new_interval
117
+
118
+ def _slow_down(self):
119
+ """Halve the playback speed."""
120
+ self._speed_multiplier *= 0.5
121
+ anim = self.viz._anim
122
+ if anim is not None:
123
+ new_interval = int(
124
+ 1000 / (self.viz.config.fps * self._speed_multiplier))
125
+ anim.event_source.interval = new_interval
126
+
127
+ def _toggle_electron_density(self):
128
+ """Toggle electron density cloud rendering."""
129
+ self.viz.config.show_electron_density = (
130
+ not self.viz.config.show_electron_density)
131
+
132
+ def _toggle_labels(self):
133
+ """Toggle time and energy labels."""
134
+ self.viz.config.show_time_label = not self.viz.config.show_time_label
135
+ self.viz.config.show_energy_bar = not self.viz.config.show_energy_bar
136
+
137
+ def _reset(self):
138
+ """Reset to the first frame."""
139
+ self._current_frame = 0
140
+ if self.viz._paused:
141
+ self.viz._render_frame(0)
142
+ self.viz.fig.canvas.draw_idle()
143
+
144
+ def _close(self):
145
+ """Close the figure."""
146
+ import matplotlib.pyplot as plt
147
+ plt.close(self.viz.fig)
148
+
149
+
150
+ def create_tkinter_panel(parent, visualizer: InteractionVisualizer):
151
+ """Create an optional tkinter control panel for GUI embedding.
152
+
153
+ Parameters
154
+ ----------
155
+ parent : tk.Widget
156
+ Parent tkinter widget.
157
+ visualizer : InteractionVisualizer
158
+ The visualizer to control.
159
+
160
+ Returns
161
+ -------
162
+ tk.Frame
163
+ The control panel frame.
164
+ """
165
+ import tkinter as tk
166
+ from tkinter import ttk
167
+
168
+ controller = PlaybackController(visualizer)
169
+
170
+ frame = ttk.Frame(parent)
171
+
172
+ play_btn = ttk.Button(
173
+ frame, text="Play/Pause",
174
+ command=controller._toggle_pause)
175
+ play_btn.pack(side="left", padx=2)
176
+
177
+ step_back_btn = ttk.Button(
178
+ frame, text="<<",
179
+ command=controller._step_backward)
180
+ step_back_btn.pack(side="left", padx=2)
181
+
182
+ step_fwd_btn = ttk.Button(
183
+ frame, text=">>",
184
+ command=controller._step_forward)
185
+ step_fwd_btn.pack(side="left", padx=2)
186
+
187
+ slower_btn = ttk.Button(
188
+ frame, text="Slower",
189
+ command=controller._slow_down)
190
+ slower_btn.pack(side="left", padx=2)
191
+
192
+ faster_btn = ttk.Button(
193
+ frame, text="Faster",
194
+ command=controller._speed_up)
195
+ faster_btn.pack(side="left", padx=2)
196
+
197
+ edensity_var = tk.BooleanVar(value=visualizer.config.show_electron_density)
198
+ edensity_cb = ttk.Checkbutton(
199
+ frame, text="e- Density",
200
+ variable=edensity_var,
201
+ command=controller._toggle_electron_density)
202
+ edensity_cb.pack(side="left", padx=2)
203
+
204
+ labels_var = tk.BooleanVar(value=visualizer.config.show_time_label)
205
+ labels_cb = ttk.Checkbutton(
206
+ frame, text="Labels",
207
+ variable=labels_var,
208
+ command=controller._toggle_labels)
209
+ labels_cb.pack(side="left", padx=2)
210
+
211
+ return frame