imperial-materials-simulation 0.0.1__tar.gz

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,10 @@
1
+ Metadata-Version: 2.1
2
+ Name: imperial_materials_simulation
3
+ Version: 0.0.1
4
+ Summary: Molecular simulation tool made for the undergraduate materials science and engineering theory and simulation module at Imperial College London
5
+ Home-page: https://github.com/AyhamSaffar/imperial_materials_simulation
6
+ Author: Ayham Al-Saffar
7
+ Requires-Python: ~=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ This is a test to make sure this comes up
@@ -0,0 +1 @@
1
+ This is a test to make sure this comes up
@@ -0,0 +1,29 @@
1
+ # __init__.py
2
+ import pandas as pd
3
+ from imperial_materials_simulation.main import Simulation, load_simulation
4
+
5
+ starting_microstructure = pd.DataFrame(
6
+ [[ 1.26130966, -3.6279824 , 3.22874575],
7
+ [ 0.01109185, -3.81654351, 2.36666413],
8
+ [-1.35980652, -3.29266717, 1.93055455],
9
+ [-2.37977009, -2.15076958, 1.87652048],
10
+ [-2.57604888, -0.67746009, 2.24917055],
11
+ [-1.87620203, 0.5914393 , 2.74767705],
12
+ [-0.5004315 , 1.22611982, 2.97495106],
13
+ [ 1.00344892, 1.14070135, 2.69818462],
14
+ [ 2.12602653, 0.50009417, 1.87770294],
15
+ [ 2.5645804 , -0.39591704, 0.7168456 ],
16
+ [ 2.18172369, -1.32983529, -0.43350902],
17
+ [ 1.10831734, -1.9090663 , -1.35772197],
18
+ [-0.30107549, -1.83240466, -1.94918892],
19
+ [-1.58628718, -1.03144403, -2.17155873],
20
+ [-2.40576488, 0.23981749, -1.93470708],
21
+ [-2.43673373, 1.66539118, -1.37583959],
22
+ [-1.61465889, 2.84516096, -0.8471113 ],
23
+ [-0.1902823 , 3.40079245, -0.74306813],
24
+ [ 1.24620565, 3.26671434, -1.25936233],
25
+ [ 2.09305162, 2.57081936, -2.32976298],
26
+ [ 2.17803079, 1.66144767, -3.55851113],
27
+ [ 1.45327508, 0.95559197, -4.70667552]],
28
+ columns=['x', 'y', 'z']
29
+ )
@@ -0,0 +1,158 @@
1
+ '''
2
+ Methods for creating interactive visualisations of the simulated molecule and its measurements
3
+ within Jupyter Notebook using ipywidgets.
4
+ '''
5
+ import ipywidgets as ipy
6
+ import py3Dmol
7
+ import matplotlib.pyplot as plt
8
+ import matplotlib
9
+ import pandas as pd
10
+ from IPython.display import display, clear_output
11
+
12
+ class SimulationDashboard():
13
+ '''
14
+ Class for creating inline Jupter Notebook visualisations of how the measurements and microstructure of simulated
15
+ molecules vary live with time. This is built on top of the library's Simulation object.
16
+ '''
17
+
18
+ def __init__(self, sim) -> None:
19
+ '''Initiates internal methods, attributes, and dashboard widgets'''
20
+ #TODO replace matplotlib with plotly for speed
21
+ matplotlib.use('module://ipympl.backend_nbagg')
22
+ self.sim = sim
23
+
24
+ self.mol_viewer = py3Dmol.view(width=300, height=300)
25
+ with plt.ioff():
26
+ self.fig, self.left_ax = plt.subplots(figsize=(6, 3))
27
+ self.fig.tight_layout(pad=2)
28
+ self.right_ax = self.left_ax.twinx()
29
+ self.line = self.right_ax.axvline(x=0.5, color='black', linestyle='--')
30
+ self.fig.canvas.header_visible = False
31
+ self.fig.canvas.toolbar_visible = False
32
+ self.fig.canvas.footer_visible = False
33
+
34
+ self.observers_enabled = False
35
+ self.run_slider = ipy.IntSlider(value=0, min=0, max=0, description='Run', orientation='vertical')
36
+ self.table_widget = ipy.Output(layout=ipy.Layout(height='200px', overflow='auto'))
37
+ with self.table_widget:
38
+ display(self.sim.run_data)
39
+ top_box = ipy.HBox(children=[self.run_slider, self.table_widget])
40
+ self.step_slider = ipy.IntSlider(value=0, min=0, max=0, step=self.sim.microstructure_logging_interval,
41
+ description='Step', orientation='Horizontal', layout=ipy.Layout(width='900px'))
42
+ self.left_axis_selector = ipy.Dropdown(options=(), description='left (red)')
43
+ self.right_axis_selector = ipy.Dropdown(options=(), description='right (blue)')
44
+ selector_box = ipy.HBox(children=[self.left_axis_selector, self.right_axis_selector])
45
+ plot_box = ipy.Output()
46
+ with plot_box:
47
+ self.fig.show()
48
+ plot_box = ipy.VBox(children=[plot_box, selector_box])
49
+ self.molecule_box = ipy.Output()
50
+ self._redraw_molecule()
51
+ bottom_box = ipy.HBox(children=[plot_box, self.molecule_box], layout=ipy.Layout(align_items='center'))
52
+ self.display_widget = ipy.VBox(children=[top_box, self.step_slider, bottom_box])
53
+ self._enable_observers()
54
+
55
+ def display(self, sim) -> None:
56
+ '''Creates an instance of the dashboard in the output of the notebook cell it is called in.'''
57
+ self.sim = sim
58
+ if self.sim.run_data['run'].max() > 0:
59
+ self.run_slider.max = self.sim.run_data['run'].max()
60
+ self.run_slider.min = 1
61
+ display(self.display_widget)
62
+
63
+ def live_update(self, sim, step: int, run_type: str, n_steps: int, temperature: float):
64
+ '''Updates the dashboard live after new runs are started'''
65
+ self.sim = sim
66
+ if step > 0:
67
+ self.step_slider.value = step
68
+ self._redraw_molecule()
69
+ self._redraw_plot()
70
+ return
71
+
72
+ #TODO freeze selectors during live update
73
+ current_run_data = {'run': self.sim.run, 'type': run_type, 'n_steps': n_steps, 'T': temperature}
74
+ with self.table_widget:
75
+ clear_output()
76
+ display(pd.concat([self.sim.run_data, pd.DataFrame(current_run_data, index=[0])]))
77
+ self._disable_observers()
78
+ self.run_slider.max += 1
79
+ self.step_slider.max = ((n_steps-1) // self.sim.microstructure_logging_interval) * self.sim.microstructure_logging_interval
80
+ self.run_slider.disabled = True
81
+ self.step_slider.disabled = True
82
+ self.run_slider.value = self.run_slider.max
83
+ self._reset_axis_selectors()
84
+
85
+ def reset(self, sim):
86
+ '''Resets the dashboard at the end of a live display run so it can continue to be used'''
87
+ self.sim = sim
88
+ self.run_slider.min = 1
89
+ self.run_slider.disabled = False
90
+ self.step_slider.disabled = False
91
+ with self.table_widget:
92
+ clear_output()
93
+ display(self.sim.run_data)
94
+ self._enable_observers()
95
+ self._redraw_plot()
96
+ self.step_slider.value = self.step_slider.max
97
+
98
+ def _step_slider_moved(self, _= None) -> None: #placeholder arguement as widget observe calls method with redundant arguement
99
+ self.step_slider.value = self.step_slider.value
100
+ self._redraw_molecule()
101
+ self.line.set_xdata([self.step_slider.value])
102
+ self.fig.canvas.draw()
103
+
104
+ def _run_slider_moved(self, _= None) -> None:
105
+ self.step_slider.value = 0
106
+ self._reset_axis_selectors()
107
+ self.step_slider.max = self.sim.step_data[self.run_slider.value].shape[0]-self.sim.microstructure_logging_interval
108
+ self._redraw_plot()
109
+ self._redraw_molecule()
110
+
111
+ def _reset_axis_selectors(self):
112
+ self._disable_observers()
113
+ self.left_axis_selector.options = self.sim.step_data[self.run_slider.value].columns[1:]
114
+ self.right_axis_selector.options = self.sim.step_data[self.run_slider.value].columns[1:]
115
+ self.left_axis_selector.value, self.right_axis_selector.value = self.sim.step_data[self.run_slider.value].columns[-2:]
116
+ self._enable_observers()
117
+
118
+ def _disable_observers(self):
119
+ if self.observers_enabled == False:
120
+ return
121
+ self.observers_enabled = False
122
+ self.step_slider.unobserve(self._step_slider_moved, names='value')
123
+ self.run_slider.unobserve(self._run_slider_moved, names='value')
124
+ self.left_axis_selector.unobserve(self._redraw_plot, names='value')
125
+ self.right_axis_selector.unobserve(self._redraw_plot, names='value')
126
+
127
+ def _enable_observers(self):
128
+ if self.observers_enabled:
129
+ return
130
+ self.observers_enabled = True
131
+ self.step_slider.observe(self._step_slider_moved, names='value')
132
+ self.run_slider.observe(self._run_slider_moved, names='value')
133
+ self.left_axis_selector.observe(self._redraw_plot, names='value')
134
+ self.right_axis_selector.observe(self._redraw_plot, names='value')
135
+
136
+ def _redraw_molecule(self, _=None) -> None:
137
+ structure = self.sim.microstructures[self.run_slider.value][self.step_slider.value].copy()
138
+ structure.insert(loc=0, column='element', value='C')
139
+ molecule_xyz = f'{len(structure)}\n some comment\n {structure.to_string(header=False, index=False)}'
140
+ self.mol_viewer.clear()
141
+ self.mol_viewer.addModel(molecule_xyz, 'xyz')
142
+ self.mol_viewer.setStyle({'stick': {'colorscheme': 'default'}, 'sphere': {'scale': 0.3, 'colorscheme': 'cyanCarbon'}})
143
+ self.mol_viewer.zoomTo()
144
+ with self.molecule_box:
145
+ self.mol_viewer.update()
146
+
147
+ def _redraw_plot(self, _=None) -> None:
148
+ left_data = self.sim.step_data[self.run_slider.value][self.left_axis_selector.value]
149
+ right_data = self.sim.step_data[self.run_slider.value][self.right_axis_selector.value]
150
+ self.left_ax.clear()
151
+ self.right_ax.clear()
152
+ self.left_ax.set_yscale('log' if left_data.max() > left_data.min()*100 and left_data.min() > 0 else 'linear')
153
+ self.right_ax.set_yscale('log' if right_data.max() > right_data.min()*100 and right_data.min() > 0 else 'linear')
154
+ self.right_ax.ticklabel_format(axis='x', style='sci', scilimits=(0,4))
155
+ self.left_ax.plot(left_data, color='red')
156
+ self.right_ax.plot(right_data, color='blue')
157
+ self.line = self.right_ax.axvline(x=self.step_slider.value, color='black', linestyle='--')
158
+ self.fig.canvas.draw()
@@ -0,0 +1,398 @@
1
+ from . import physics, display
2
+ import numpy as np
3
+ import numpy.random as rand
4
+ import pandas as pd
5
+ import pickle
6
+ import warnings
7
+
8
+
9
+ class Simulation():
10
+ '''
11
+ A script for atomistic simulations of toy polymers. Each polymer is a linear string of beads
12
+ (CH2 units) with no side chains. Bonds are springs and nonbonded atoms interact via a 12-6
13
+ Lennard-Jones potential.
14
+
15
+ Four methods of atomistic simulation are implemented:
16
+
17
+ (1) Steepest descent structural relaxation / energy minimization.
18
+
19
+ (2) Constant temperature ('NVT') dynamics with a Langevin thermostat.
20
+
21
+ (3) Hamiltonian (aka `constant energy' or `NVE') molecular dynamics.
22
+
23
+ (4) Metropolis Monte Carlo ('MMC') stochastic model.
24
+
25
+ Paul Tangney, Ayham Al-Saffar 2024
26
+ '''
27
+
28
+ def __init__(self, n_atoms: int, starting_temperature: float, microstructure: pd.DataFrame = None,
29
+ microstructure_logging_interval: int = 100) -> None:
30
+ '''
31
+ Initializes internal microstate, physics, and display variables.
32
+ Units are in electron volts, femto-seconds, Angstroms, and Kelvin.
33
+
34
+ Parameters
35
+ ----------
36
+ n_atoms : int
37
+ Number of atoms in the simluated carbon chain.
38
+
39
+ starting_temperature : int
40
+ Temperature used when initiating atom velocities.
41
+
42
+ microstructure : pd.DataFrame
43
+ Dataframe specifying the intitial positions of each atom. Dataframe must have n_atoms rows
44
+ and the columns 'x', 'y', 'z'. If not given, a straight linear chain is created.
45
+
46
+ microstructure_logging_interval : int
47
+ How many steps are run between each time the microstrucutre is saved. This can be reduced
48
+ to as low as 10 without significantly impacting run speed, however this significantly
49
+ increases the size of the saved simulation file.
50
+ '''
51
+ self.n_atoms = n_atoms
52
+ self.microstructure_logging_interval = microstructure_logging_interval
53
+ self.is_being_displayed = False
54
+ self.time_step = 0.8
55
+ self.mass = 1451.0 # 14 AMU (CH2 Mr) in eV fs^2/Å^2
56
+
57
+ #force parameters taken from https://doi.org/10.1063/1.476826
58
+ self.sigma = 4.5 #equilibrium Lennard Jones atomic seperation
59
+ self.epsilon = 0.00485678 #Lennard Jones coefficient
60
+ self.bond_length = 1.53 #equilibrium neighbour bond length
61
+ self.spring_constant = 15.18
62
+
63
+ #increases initial Boltzmann distribution velocities when generating a new microstate as some
64
+ #of this energy is converted to potential energy. Its value should be 2.0 for a harmonic potential.
65
+ T_factor = 1.5 if microstructure is None else 1.0 #! Will discuss what this value should be on Friday
66
+ self.kB = 8.617333262e-05
67
+ self.target_kT = starting_temperature * self.kB
68
+ self.velocity_sigma = np.sqrt(self.target_kT/self.mass) #variance of Boltzmann distrubution velocities
69
+ self.velocities = rand.normal(loc=0.0, scale=np.sqrt(T_factor)*self.velocity_sigma, size=(self.n_atoms, 3))
70
+
71
+ self.forces = np.zeros(shape=(self.n_atoms, 3))
72
+ if microstructure is None:
73
+ self.positions = np.zeros(shape=(self.n_atoms, 3))
74
+ self.positions[:, 1] = np.linspace(start=0.0, stop=(self.n_atoms-1)*self.bond_length, num=self.n_atoms)
75
+ else:
76
+ assert microstructure.shape[0] == self.n_atoms
77
+ assert all([col in microstructure.columns for col in ['x', 'y', 'z']])
78
+ self.positions = microstructure[['x', 'y', 'z']].values
79
+
80
+ self.positions -= np.mean(self.positions, axis=0, keepdims=True) #centre molecule
81
+ self.velocities -= np.mean(self.velocities, axis=0, keepdims=True) #remove overall molecule movement
82
+
83
+ #list with a dict {step: microstructure} for each run
84
+ self.microstructures = [{0: pd.DataFrame(self.positions, columns=['x', 'y', 'z']).copy()}]
85
+ self.run = 0
86
+ data_structure = {'run': int, 'type': str, 'n_steps': int, 'T': float, 'KE': float, 'PE_bonding': float,
87
+ 'PE_non_bonding': float, 'PE_total': float, 'F_rms': float, 'L_end_to_end': float}
88
+ self.run_data = pd.DataFrame({name: pd.Series(dtype=dtype) for name, dtype in data_structure.items()})
89
+ self._log_run_data(run_type='init', n_steps=0, temperature=starting_temperature)
90
+ self.step_data = {} #{run: dataframe}
91
+
92
+
93
+ def relax_run(self, n_steps: int, step_size: float = 0.01) -> None:
94
+ '''
95
+ Structural relaxation through steepest descent energy minimisation. This is a non-physical simulation
96
+ where the molecule is modelled as not experiencing any thermal forces. Each atom moves in the direction
97
+ that minimizes is potential energy the most, which is the net (bonding + non-bonding) force vector.
98
+
99
+ Parameters
100
+ ----------
101
+ n_steps : int
102
+ Number of steps of steepest descent energy minimisation.
103
+
104
+ step_size: float
105
+ How far each atom should move with each step. The total movement vector is the relax_step_size * force
106
+ vector. 0.01 is a good starting point, but could be ~10x higher for the first few steps and ~10x smaller
107
+ for the last few steps.
108
+
109
+ '''
110
+ self.run += 1
111
+ self.microstructures.append({0: pd.DataFrame(self.positions, columns=['x', 'y', 'z'])})
112
+ step_data = []
113
+
114
+ for step in range(n_steps):
115
+ F_b, PE_b = physics.get_bonding_interactions(self.positions, self.bond_length, self.spring_constant)
116
+ F_nb, PE_nb = physics.get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
117
+ self.positions += (F_nb + F_b) * step_size
118
+ step_data.append({
119
+ 'step': step,
120
+ 'PE_bonding': PE_b,
121
+ 'PE_non_bonding': PE_nb,
122
+ 'PE_total': PE_b + PE_nb,
123
+ 'F_rms': np.mean((F_b + F_nb)**2) ** 0.5,
124
+ 'L_end_to_end': np.sum((self.positions[0] - self.positions[-1]) ** 2) ** 0.5,
125
+ })
126
+ self._logging_step(step, step_data, run_type='relax', n_steps=n_steps, temperature=0.0)
127
+
128
+ self.step_data[self.run] = pd.DataFrame(step_data)
129
+ self._log_run_data(run_type='relax', n_steps=n_steps, temperature=0.0)
130
+ self.dashboard.reset(self) if self.is_being_displayed else print(f'{n_steps:,} step relax run completed')
131
+
132
+ def NVT_run(self, n_steps: int, temperature: float, gamma: float = 0.005, integrator: str = 'OVRVO'):
133
+ '''
134
+ Constant number of atoms, volume, and temperature (NVT) simulation with a Langevin thermostat. Simple
135
+ model that approximates the physical influence of a solute bath at a given temperature. It adds a drag
136
+ and random force onto the energy minimization force. https://doi.org/10.1021/jp411770f details different
137
+ techniques for integrating these forces over time.
138
+
139
+ Parameters
140
+ ----------
141
+ n_steps : int
142
+ Number of NVT steps to perform.
143
+
144
+ temperature: float
145
+ Temperature of simulation. A higher temperature leads to higher solute forces and so velocities. Note
146
+ the actual temperature of the simulation at each step (dictated by atom velocities) may vary slightly.
147
+
148
+ gamma: float
149
+ Solvent interaction strength. Higher values lead to higher solute drag and random solute forces. 0 is
150
+ no solvent, 0.001 is weak interaction, 0.005 is medium interaction, and 0.1 is strong interaction
151
+
152
+ integrator: str
153
+ How forces are intergrated over time. Either 'OVRVO' or 'VRORV'. See paper for more details.
154
+ #TODO make all available
155
+ '''
156
+ assert integrator in ('OVRVO', 'VRORV'), f'{integrator} integrator not supported'
157
+ self.run += 1
158
+ self.microstructures.append({0: pd.DataFrame(self.positions, columns=['x', 'y', 'z'])})
159
+ step_data = []
160
+
161
+ self.target_kT = temperature * self.kB
162
+ self.velocity_sigma = np.sqrt(self.target_kT/self.mass) #variance of Boltzmann distrubution velocities
163
+
164
+ F_b, PE_b = physics.get_bonding_interactions(self.positions, self.bond_length, self.spring_constant)
165
+ F_nb, PE_nb = physics.get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
166
+ a = np.exp(-gamma / (self.time_step*2)) if integrator == 'OVRVO' else np.exp(-gamma/self.time_step)
167
+ b = np.sqrt(1 - a**2)
168
+ for step in range(n_steps):
169
+ if integrator == 'OVRVO':
170
+ self.velocities = self.velocities*a + rand.standard_normal((self.n_atoms, 3))*self.velocity_sigma*b
171
+ self.velocities += (F_b + F_nb)*self.time_step / (2*self.mass)
172
+ self.positions += self.velocities*self.time_step
173
+ F_b, PE_b = physics.get_bonding_interactions(self.positions, self.bond_length, self.spring_constant)
174
+ F_nb, PE_nb = physics.get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
175
+ self.velocities += (F_b + F_nb)*self.time_step / (2*self.mass)
176
+ self.velocities = self.velocities*a + rand.standard_normal((self.n_atoms, 3))*self.velocity_sigma*b
177
+ elif integrator == 'VRORV':
178
+ self.velocities += (F_b + F_nb)*self.time_step / (2*self.mass)
179
+ self.positions += self.velocities*self.time_step / 2
180
+ self.velocities = self.velocities*a + rand.standard_normal((self.n_atoms, 3))*self.velocity_sigma*b
181
+ self.positions += self.velocities*self.time_step / 2
182
+ F_b, PE_b = physics.get_bonding_interactions(self.positions, self.bond_length, self.spring_constant)
183
+ F_nb, PE_nb = physics.get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
184
+ self.velocities += (F_b + F_nb)*self.time_step / (2*self.mass)
185
+
186
+ ##TODO make sure it is okay that velocities are not re-normalised at this point
187
+ KE_total = physics.get_kintetic_energy(self.velocities, self.mass)
188
+ T_actual = physics.get_temperature(KE_total, self.n_atoms)
189
+ step_data.append({
190
+ 'step': step,
191
+ 'T_actual': T_actual,
192
+ 'PE_total': PE_b + PE_nb,
193
+ 'KE_total': KE_total,
194
+ 'F_rms': np.mean((F_b + F_nb)**2) ** 0.5,
195
+ 'L_end_to_end': np.sum((self.positions[0] - self.positions[-1]) ** 2) ** 0.5,
196
+ })
197
+ self._logging_step(step, step_data, run_type='NVT', n_steps=n_steps, temperature=temperature)
198
+
199
+ self.step_data[self.run] = pd.DataFrame(step_data)
200
+ self._log_run_data(run_type='NVT', n_steps=n_steps, temperature=temperature)
201
+ self.dashboard.reset(self) if self.is_being_displayed else print(f'{n_steps:,} step NVT run completed')
202
+
203
+ def NVE_run(self, n_steps: int, temperature: float = None, integrator: str = 'step'):
204
+ '''
205
+ Constant number of atoms, volume, and energy (NVE) simulation. Used for modelling molecules in the
206
+ gas phase or finding an acceptable simulation timestep. Larger timesteps speed up the simulation
207
+ but will eventually break conservation of energy if too large. https://doi.org/10.1021/jp411770f
208
+ details different techniques for integrating the simulation forces over time.
209
+
210
+ Parameters
211
+ ----------
212
+ n_steps : int
213
+ Number of NVE steps to perform.
214
+
215
+ temperature: float
216
+ If given, resets the atom velocities to follow a boltzmann distribtuion at the
217
+ new tempearture. Should be given if this run is not preceded by an NVT run at
218
+ the desired temperature.
219
+
220
+ integrator : str
221
+ How forces are intergrated over time. Either 'continuous' or 'step'.
222
+ #TODO make all available
223
+ '''
224
+ assert integrator in ['continuous', 'step'], f'integrator {integrator} not supported'
225
+ self.run += 1
226
+ self.microstructures.append({0: pd.DataFrame(self.positions, columns=['x', 'y', 'z'])})
227
+ step_data = []
228
+
229
+ if temperature is not None:
230
+ self.target_kT = temperature * self.kB
231
+ self.velocity_sigma = np.sqrt(self.target_kT/self.mass) #variance of Boltzmann distrubution velocities
232
+ self.velocities = rand.normal(loc=0.0, scale=self.velocity_sigma, size=(self.n_atoms, 3))
233
+ else:
234
+ temperature = self.run_data.loc[self.run_data['run'] == (self.run-1), 'T'].values[0] #previous temperature
235
+ F_b, PE_b = physics.get_bonding_interactions(self.positions, self.bond_length, self.spring_constant)
236
+ F_nb, PE_nb = physics.get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
237
+
238
+ for step in range(n_steps):
239
+ if integrator == 'continuous':
240
+ average_velocities = self.velocities + (F_b + F_nb)*self.time_step / (2*self.mass)
241
+ self.positions += average_velocities*self.time_step
242
+ F_b, PE_b = physics.get_bonding_interactions(self.positions, self.bond_length, self.spring_constant)
243
+ F_nb, PE_nb = physics.get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
244
+ self.velocities = average_velocities + (F_b + F_nb)*self.time_step / (2*self.mass)
245
+ if integrator == 'step':
246
+ self.velocities += (F_b + F_nb)*self.time_step / (2*self.mass)
247
+ self.positions += self.velocities*self.time_step
248
+ F_b, PE_b = physics.get_bonding_interactions(self.positions, self.bond_length, self.spring_constant)
249
+ F_nb, PE_nb = physics.get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
250
+ self.velocities += (F_b + F_nb)*self.time_step / (2*self.mass)
251
+
252
+ KE_total = physics.get_kintetic_energy(self.velocities, self.mass)
253
+ step_data.append({
254
+ 'step': step,
255
+ 'PE_total': PE_b + PE_nb,
256
+ 'KE_total': KE_total,
257
+ 'F_rms': np.mean((F_b + F_nb)**2) ** 0.5,
258
+ 'L_end_to_end': np.sum((self.positions[0] - self.positions[-1]) ** 2) ** 0.5,
259
+ })
260
+ self._logging_step(step, step_data, run_type='NVE', n_steps=n_steps, temperature=temperature)
261
+
262
+ self.step_data[self.run] = pd.DataFrame(step_data)
263
+ self._log_run_data(run_type='NVE', n_steps=n_steps, temperature=temperature)
264
+ self.dashboard.reset(self) if self.is_being_displayed else print(f'{n_steps:,} step NVE run completed')
265
+
266
+ def MMC_run(self, n_steps: int, temperature: float, random_scale: float = 0.05):
267
+ '''
268
+ Metropolis Monte Carlo (MMC) Simulation. Randomly displaces atoms by a small amount and accepts the new
269
+ structure if it reduces total potential energy or has a chance of accepting the new structure ∝ exp(-ΔPE)
270
+ if it increases total potential energy. Un-physical but useful for sampling a variety of fairly low energy
271
+ microstructures for thermodynamic property prediction.
272
+
273
+ Parameters
274
+ ----------
275
+ n_steps : int
276
+ Number of MMC steps to perform.
277
+
278
+ temperature: float
279
+ Temperature of simulation. A higher temperature means higher thermal energy, increasing tha chance
280
+ of an atom moving to a new random position.
281
+
282
+ random_scale: float
283
+ Size limit of random displacement at each step. Will be +/- bond_length * random_scale / 2 in each direction.
284
+ '''
285
+ self.run += 1
286
+ self.microstructures.append({0: pd.DataFrame(self.positions, columns=['x', 'y', 'z'])})
287
+ step_data = []
288
+
289
+ self.target_kT = temperature * self.kB
290
+ self.velocity_sigma = np.sqrt(self.target_kT/self.mass) #variance of Boltzmann distrubution velocities
291
+
292
+ energy_tracker = physics.PotentialEnergyTracker(self.positions, self.epsilon, self.sigma,
293
+ self.bond_length, self.spring_constant)
294
+ PE = energy_tracker.get_total_potential_energy()
295
+ max_displacement_size = self.bond_length * random_scale
296
+ atom_indexes = rand.randint(low=0, high=self.n_atoms-1, size=n_steps)
297
+ displacements = rand.uniform(low=-max_displacement_size/2, high=max_displacement_size/2, size=(n_steps, 3))
298
+ for step in range(n_steps):
299
+ is_displacement_accepted = False
300
+ PE_change = energy_tracker.test_displacement(atom_indexes[step], displacements[step])
301
+ if PE_change < 0 or np.exp(-PE_change / self.target_kT) > rand.random():
302
+ is_displacement_accepted = True
303
+ energy_tracker.accept_last_displacement()
304
+ PE += PE_change
305
+ self.positions[atom_indexes[step]] += displacements[step]
306
+
307
+ step_data.append({
308
+ 'step': step,
309
+ 'displacement_accepted': is_displacement_accepted,
310
+ 'PE_total': PE,
311
+ 'L_end_to_end': np.sum((self.positions[0] - self.positions[-1]) ** 2) ** 0.5,
312
+ })
313
+ self._logging_step(step, step_data, run_type='MMC', n_steps=n_steps, temperature=temperature)
314
+
315
+ self.step_data[self.run] = pd.DataFrame(step_data)
316
+ self._log_run_data(run_type='MMC', n_steps=n_steps, temperature=temperature)
317
+ self.dashboard.reset(self) if self.is_being_displayed else print(f'{n_steps:,} step MMC run completed')
318
+
319
+ def display(self, display_interval: int = 1_000):
320
+ '''
321
+ Create an interactive dashboard that displays how the molecule's conformation and physical properties
322
+ change throughout the simulation. The dashboard can only be displayed in Jupyter Notebook. The dashboard
323
+ will update live if this method is called before a simulation run is started.
324
+
325
+ Parameters
326
+ ----------
327
+ display_interval : int
328
+ How many steps are run between each live display update. A larger interval will significantly speed
329
+ up longer runs. Note MMC runs have a 5x longer interval due to their higher run speed.
330
+ '''
331
+ assert display_interval % self.microstructure_logging_interval == 0,\
332
+ f'please select a display interval that is a multiple of {self.microstructure_logging_interval}'
333
+ self.display_interval = display_interval
334
+ try:
335
+ shell = get_ipython().__class__.__name__
336
+ if shell == 'ZMQInteractiveShell':
337
+ self.is_being_displayed = True
338
+ self.dashboard = display.SimulationDashboard(self)
339
+ self.dashboard.display(self)
340
+ else:
341
+ warnings.warn(f'This functionality is only available in a Jupyter Notebook. {shell} enviroment detected')
342
+ except NameError:
343
+ warnings.warn('This functionality is only available in a Jupyter Notebook')
344
+
345
+ def save(self, path: str) -> None:
346
+ '''
347
+ Save simulation object as a btye file.
348
+
349
+ Parameters
350
+ ----------
351
+ path : str
352
+ File location where Simulation object will be stored.
353
+ '''
354
+ with open(path, mode='wb') as file:
355
+ pickle.dump(self, file)
356
+
357
+ def _log_run_data(self, run_type: str, n_steps: int, temperature: float) -> None:
358
+ '''logs the final state of molecule after each run and stores it in the self.run_data dataframe'''
359
+ F_b, PE_b = physics.get_bonding_interactions(self.positions, self.bond_length, self.spring_constant)
360
+ F_nb, PE_nb = physics.get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
361
+ current_state = {
362
+ 'run': self.run,
363
+ 'type': run_type,
364
+ 'n_steps': n_steps,
365
+ 'T': temperature,
366
+ 'KE': physics.get_kintetic_energy(self.velocities, self.mass),
367
+ 'PE_bonding': PE_b,
368
+ 'PE_non_bonding': PE_nb,
369
+ 'PE_total': PE_b + PE_nb,
370
+ 'F_rms': np.mean((F_b + F_nb)**2) ** 0.5,
371
+ 'L_end_to_end': np.sum((self.positions[0] - self.positions[-1]) ** 2) ** 0.5,
372
+ }
373
+ self.run_data = pd.concat([self.run_data, pd.DataFrame([current_state])])
374
+
375
+ def _logging_step(self, step: int, step_data: list[dict], run_type: str, n_steps: int, temperature: float) -> None:
376
+ '''Saves microstructure and updates display at appropriate steps'''
377
+ if step % self.microstructure_logging_interval == 0:
378
+ self.positions -= np.mean(self.positions, axis=0, keepdims=True) #centre molecule
379
+ self.microstructures[self.run][step] = pd.DataFrame(self.positions, columns=['x', 'y', 'z']).copy()
380
+
381
+ if self.is_being_displayed == False:
382
+ return
383
+ display_interval = 5 * self.display_interval if run_type == 'MMC' else self.display_interval
384
+ if step % display_interval == 0:
385
+ self.step_data[self.run] = pd.DataFrame(step_data)
386
+ self.dashboard.live_update(self, step, run_type, n_steps, temperature)
387
+
388
+ def load_simulation(path: str) -> Simulation:
389
+ '''
390
+ Load in a simulation object from a file. For safety reasons, only load files you have created.
391
+
392
+ Parameters
393
+ ----------
394
+ path : str
395
+ Simulation file location to be read in.
396
+ '''
397
+ with open(path, mode='rb') as file:
398
+ return pickle.load(file)
@@ -0,0 +1,146 @@
1
+ '''
2
+ Vectorised functions and classes for efficiently calculating kintetic energy, temperature, spring bonding
3
+ interactions, and Lennard Jones long range interactions.
4
+ '''
5
+ import numpy as np
6
+ #compiles functions for much higher speed when called many times. Limited compatibility with numpy
7
+ import numba as nb
8
+
9
+ @nb.jit(nopython=True, fastmath=True)
10
+ def get_kintetic_energy(velocities, mass) -> np.float64:
11
+ '''returns total kinetic energy of all atoms'''
12
+ return np.sum(mass/2 * velocities**2)
13
+
14
+ @nb.jit(nopython=True, fastmath=True)
15
+ def get_temperature(kinetic_energy: np.float64, n_atoms: int) -> np.float64:
16
+ '''returns the average temperature of all the atoms'''
17
+ kb = 8.617333262e-05
18
+ return 2 * kinetic_energy / (3 * (n_atoms-1) * kb)
19
+
20
+ @nb.jit(nopython=True, fastmath=True) #fastmath increases float64 innaccuracy by 4x (1.1e-16 to 4.4e-16) which is acceptable here
21
+ def get_bonding_interactions(positions: np.ndarray, equilibrium_bond_length: float, spring_constant: float) \
22
+ -> list[np.ndarray, float]:
23
+ '''returns bonding forces for each atom and the total bonding potential of all atoms'''
24
+ bond_displacements = positions[1:] - positions[:-1]
25
+ bond_lengths = np.sum(bond_displacements**2, axis=1) ** 0.5
26
+ bond_lengths = bond_lengths.reshape(bond_lengths.shape[0], 1) #enables broadcasting in bond direction calculation
27
+ bond_extensions = bond_lengths - equilibrium_bond_length
28
+ bond_directions = bond_displacements / bond_lengths
29
+
30
+ bond_forces = -spring_constant * bond_extensions * bond_directions
31
+ atom_forces = np.zeros(shape=positions.shape)
32
+ atom_forces[:-1] -= bond_forces #each bond exerts a negative force on the left atom
33
+ atom_forces[1:] += bond_forces #each bond exerts a positive force on the right atom
34
+ total_bond_potential = np.sum(spring_constant/2 * (bond_extensions**2))
35
+ return atom_forces, total_bond_potential
36
+
37
+ #numpy broadcasting can be used to replace the for loop and increase speed. This however requires boolean indexing
38
+ #which is not supported by numba and so the proper numpy implementation actually ends up slower.
39
+ @nb.jit(nopython=True, fastmath=True)
40
+ def get_non_bonding_interactions(positions: np.ndarray, epsilon: float, sigma: float) -> list[np.ndarray, float]:
41
+ '''
42
+ returns the Lennard Jones (LJ) force for each atom and the total LJ potential of all atoms. This approximates
43
+ long range Van Der Valls attraction as well as the hard sphere repulsion of each atom to its more distant neighbours
44
+ (equilibrium LJ seperation is ~3x the equilibrium bond seperation)
45
+ '''
46
+ n_atoms = positions.shape[0]
47
+ forces = np.zeros((n_atoms,3))
48
+ potential = 0.0
49
+ for i in range(n_atoms-1):
50
+ #displacements from atom i to its 3rd nearest neighbour and beyond (LJ models long range interactions). Looks
51
+ #like it ignores rightmost atoms but only find the displacement between relevant pairs once for efficiency
52
+ displacements = positions[i+3:] - positions[i]
53
+ lengths = np.sum(displacements**2, axis=1) ** 0.5
54
+ lengths = lengths.reshape(lengths.shape[0], 1) #allows broadcasting in following calculations
55
+ directions = displacements / lengths
56
+ sixpowers = (sigma/lengths) ** 6
57
+ twelvepowers = sixpowers ** 2
58
+ potential += np.sum(epsilon * (twelvepowers - 2*sixpowers))
59
+
60
+ #forces exerted by atom i on its relevant neighbours
61
+ atom_forces = epsilon * 12/lengths * (twelvepowers - sixpowers) * directions
62
+ forces[i+3:] += atom_forces
63
+ forces[i] -= np.sum(atom_forces, axis=0) #equal and opposite reaction on atom
64
+
65
+ return forces, potential
66
+
67
+ @nb.jit(nopython=True, fastmath=True)
68
+ def get_energy_change(positions: np.ndarray, displacements: np.ndarray, lengths: np.ndarray, atom_index: int,
69
+ displacement: np.ndarray, epsilon: float, sigma: float, equilibrium_bond_length: float,
70
+ spring_constant: float) -> list[float, np.ndarray, np.ndarray, np.ndarray]:
71
+ '''
72
+ calculates only affected atom seperations to find how total potential energy changes. Really requires
73
+ you to draw out a mock displacement matrix and pick an atom index to understand the array indexing.
74
+ returns energy_change, new_positions, new_displacements, new_lengths
75
+ '''
76
+ i = atom_index
77
+ new_positions = positions.copy()
78
+ new_displacements = displacements.copy()
79
+ new_lengths = lengths.copy()
80
+ n_atoms = new_positions.shape[0]
81
+ energy_change = 0.0
82
+
83
+ new_positions[i] += displacement
84
+ new_displacements[i, i+1: ] = new_positions[i: i+1] - new_positions[i+1: ]
85
+ new_lengths[i, i+1: ] = np.sum(new_displacements[i, i+1: ] ** 2, axis=1) ** 0.5
86
+ new_displacements[:i, i] = new_positions[:i] - new_positions[i: i+1]
87
+ new_lengths[:i, i] = np.sum(new_displacements[:i, i] ** 2, axis=1) ** 0.5
88
+
89
+ if i != 0:
90
+ extension = np.abs(lengths[i-1, i] - equilibrium_bond_length)
91
+ new_extension = np.abs(new_lengths[i-1, i] - equilibrium_bond_length)
92
+ energy_change += 0.5 * spring_constant * (new_extension**2 - extension**2)
93
+ if i != n_atoms-1:
94
+ extension = np.abs(lengths[i, i+1] - equilibrium_bond_length)
95
+ new_extension = np.abs(new_lengths[i, i+1] - equilibrium_bond_length)
96
+ energy_change += 0.5 * spring_constant * (new_extension**2 - extension**2)
97
+
98
+ #Lennard Jones Potentials ignore 2 nearest neighbours of each atom
99
+ sixpowers = (sigma / lengths[i, i+3: ]) ** 6
100
+ new_sixpowers = (sigma / new_lengths[i, i+3: ]) ** 6
101
+ energy_change += epsilon * (np.sum(new_sixpowers**2 - 2*new_sixpowers) - np.sum(sixpowers**2 - 2*sixpowers))
102
+ if i > 1: #lengths[:i-2, i] returns unintended values if (i-2) is negative
103
+ sixpowers = (sigma / lengths[:i-2, i]) ** 6
104
+ new_sixpowers = (sigma / new_lengths[:i-2, i]) ** 6
105
+ energy_change += epsilon * (np.sum(new_sixpowers**2 - 2*new_sixpowers) - np.sum(sixpowers**2 - 2*sixpowers))
106
+
107
+ return energy_change, new_positions, new_displacements, new_lengths
108
+
109
+ class PotentialEnergyTracker():
110
+ '''
111
+ Utility class for efficiently tracking how total potential energy (bonding + Lennard Jones non-bonding) changes
112
+ when a single atom is displaced.
113
+ '''
114
+
115
+ def __init__(self, positions: np.ndarray, epsilon: float, sigma: float, equilibrium_bond_length: float,
116
+ spring_constant: float) -> None:
117
+ 'calculates key distances, lengths and total potential energy'
118
+ self.positions = positions
119
+ self.epsilon = epsilon
120
+ self.sigma = sigma
121
+ self.equilibrium_bond_length = equilibrium_bond_length
122
+ self.spring_constant = spring_constant
123
+ self.n_atoms = len(positions)
124
+
125
+ # (n_atoms x n_atoms) array where array[i, j] is the displacement going from j to i
126
+ self.displacements = self.positions.reshape(self.n_atoms, 1, 3) - self.positions.reshape(1, self.n_atoms, 3)
127
+ self.lengths = np.sum(self.displacements ** 2, axis=2) ** 0.5
128
+
129
+ def get_total_potential_energy(self):
130
+ '''return total potential energy of molecule'''
131
+ _, bonding_potential = get_bonding_interactions(self.positions, self.equilibrium_bond_length, self.spring_constant)
132
+ _, non_bonding_potential = get_non_bonding_interactions(self.positions, self.epsilon, self.sigma)
133
+ return bonding_potential + non_bonding_potential
134
+
135
+ def test_displacement(self, atom_index: int, displacement: np.ndarray) -> float:
136
+ '''stores temporary new positions, displacements, and lengths. Returns change in potential energy'''
137
+ result = get_energy_change(self.positions, self.displacements, self.lengths, atom_index, displacement, self.epsilon,
138
+ self.sigma, self.equilibrium_bond_length, self.spring_constant)
139
+ energy_change, self.new_positions, self.new_displacements, self.new_lengths = result
140
+ return energy_change
141
+
142
+ def accept_last_displacement(self) -> None:
143
+ '''replaces internal positions, displacements, and lengths with values from last test displacement'''
144
+ self.positions = self.new_positions
145
+ self.displacements = self.new_displacements
146
+ self.lengths = self.new_lengths
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.1
2
+ Name: imperial-materials-simulation
3
+ Version: 0.0.1
4
+ Summary: Molecular simulation tool made for the undergraduate materials science and engineering theory and simulation module at Imperial College London
5
+ Home-page: https://github.com/AyhamSaffar/imperial_materials_simulation
6
+ Author: Ayham Al-Saffar
7
+ Requires-Python: ~=3.10
8
+ Description-Content-Type: text/markdown
9
+
10
+ This is a test to make sure this comes up
@@ -0,0 +1,11 @@
1
+ README.md
2
+ setup.py
3
+ imperial_materials_simulation/__init__.py
4
+ imperial_materials_simulation/display.py
5
+ imperial_materials_simulation/main.py
6
+ imperial_materials_simulation/physics.py
7
+ imperial_materials_simulation.egg-info/PKG-INFO
8
+ imperial_materials_simulation.egg-info/SOURCES.txt
9
+ imperial_materials_simulation.egg-info/dependency_links.txt
10
+ imperial_materials_simulation.egg-info/requires.txt
11
+ imperial_materials_simulation.egg-info/top_level.txt
@@ -0,0 +1,9 @@
1
+ ipykernel
2
+ ipympl
3
+ ipywidgets
4
+ matplotlib
5
+ numba>=0.60.0
6
+ numpy
7
+ pandas
8
+ py3dmol
9
+ scipy
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,31 @@
1
+ #python setup.py sdist bdist_wheel
2
+ #twine upload --skip-existing dist/*
3
+ #twine upload --skip-existing --repository testpypi dist/*
4
+
5
+ from setuptools import setup, find_packages
6
+
7
+ with open('README.md', mode='r') as file:
8
+ description = file.read()
9
+
10
+ setup(
11
+ name = 'imperial_materials_simulation',
12
+ version = '0.0.1',
13
+ description = 'Molecular simulation tool made for the undergraduate materials science and engineering theory and simulation module at Imperial College London',
14
+ author = 'Ayham Al-Saffar',
15
+ url = 'https://github.com/AyhamSaffar/imperial_materials_simulation',
16
+ packages = find_packages(),
17
+ python_requires = '~=3.10',
18
+ install_requires = [
19
+ 'ipykernel',
20
+ 'ipympl',
21
+ 'ipywidgets',
22
+ 'matplotlib',
23
+ 'numba>=0.60.0',
24
+ 'numpy',
25
+ 'pandas',
26
+ 'py3dmol',
27
+ 'scipy',
28
+ ],
29
+ long_description = description,
30
+ long_description_content_type = 'text/markdown',
31
+ )