firecode 1.0.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.
Files changed (59) hide show
  1. firecode/TEST_NOTEBOOK.ipynb +3940 -0
  2. firecode/__init__.py +0 -0
  3. firecode/__main__.py +118 -0
  4. firecode/_gaussian.py +97 -0
  5. firecode/algebra.py +405 -0
  6. firecode/ase_manipulations.py +879 -0
  7. firecode/atropisomer_module.py +516 -0
  8. firecode/automep.py +130 -0
  9. firecode/calculators/__init__.py +29 -0
  10. firecode/calculators/_gaussian.py +98 -0
  11. firecode/calculators/_mopac.py +242 -0
  12. firecode/calculators/_openbabel.py +154 -0
  13. firecode/calculators/_orca.py +129 -0
  14. firecode/calculators/_xtb.py +786 -0
  15. firecode/concurrent_test.py +119 -0
  16. firecode/embedder.py +2590 -0
  17. firecode/embedder_options.py +577 -0
  18. firecode/embeds.py +881 -0
  19. firecode/errors.py +65 -0
  20. firecode/graph_manipulations.py +333 -0
  21. firecode/hypermolecule_class.py +364 -0
  22. firecode/mep_relaxer.py +199 -0
  23. firecode/modify_settings.py +186 -0
  24. firecode/mprof.py +65 -0
  25. firecode/multiembed.py +148 -0
  26. firecode/nci.py +186 -0
  27. firecode/numba_functions.py +260 -0
  28. firecode/operators.py +776 -0
  29. firecode/optimization_methods.py +609 -0
  30. firecode/parameters.py +84 -0
  31. firecode/pka.py +275 -0
  32. firecode/profiler.py +17 -0
  33. firecode/pruning.py +421 -0
  34. firecode/pt.py +32 -0
  35. firecode/quotes.json +6651 -0
  36. firecode/quotes.py +9 -0
  37. firecode/reactive_atoms_classes.py +666 -0
  38. firecode/references.py +11 -0
  39. firecode/rmsd.py +74 -0
  40. firecode/settings.py +75 -0
  41. firecode/solvents.py +126 -0
  42. firecode/tests/C2F2H4.xyz +10 -0
  43. firecode/tests/C2H4.xyz +8 -0
  44. firecode/tests/CH3Cl.xyz +7 -0
  45. firecode/tests/HCOOH.xyz +7 -0
  46. firecode/tests/HCOOOH.xyz +8 -0
  47. firecode/tests/chelotropic.txt +3 -0
  48. firecode/tests/cyclical.txt +3 -0
  49. firecode/tests/dihedral.txt +2 -0
  50. firecode/tests/string.txt +3 -0
  51. firecode/tests/trimolecular.txt +9 -0
  52. firecode/tests.py +151 -0
  53. firecode/torsion_module.py +1035 -0
  54. firecode/utils.py +541 -0
  55. firecode-1.0.0.dist-info/LICENSE +165 -0
  56. firecode-1.0.0.dist-info/METADATA +321 -0
  57. firecode-1.0.0.dist-info/RECORD +59 -0
  58. firecode-1.0.0.dist-info/WHEEL +5 -0
  59. firecode-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,879 @@
1
+ # coding=utf-8
2
+ '''
3
+ FIRECODE: Filtering Refiner and Embedder for Conformationally Dense Ensembles
4
+ Copyright (C) 2021-2024 Nicolò Tampellini
5
+
6
+ SPDX-License-Identifier: LGPL-3.0-or-later
7
+
8
+ This program is free software: you can redistribute it and/or modify
9
+ it under the terms of the GNU Lesser General Public License as published by
10
+ the Free Software Foundation, either version 3 of the License, or
11
+ (at your option) any later version.
12
+
13
+ This program is distributed in the hope that it will be useful,
14
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
15
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16
+ GNU Lesser General Public License for more details.
17
+
18
+ You should have received a copy of the GNU Lesser General Public License
19
+ along with this program. If not, see
20
+ https://www.gnu.org/licenses/lgpl-3.0.en.html#license-text.
21
+
22
+ '''
23
+
24
+ import os
25
+ import time
26
+ import warnings
27
+ from copy import deepcopy
28
+
29
+ import matplotlib.pyplot as plt
30
+ import numpy as np
31
+ from ase import Atoms
32
+ from ase.calculators.calculator import (CalculationFailed,
33
+ PropertyNotImplementedError)
34
+ from ase.calculators.gaussian import Gaussian
35
+ from ase.calculators.mopac import MOPAC
36
+ from ase.calculators.orca import ORCA
37
+ from ase.constraints import FixInternals
38
+ from ase.dyneb import DyNEB
39
+ from ase.optimize import BFGS, LBFGS
40
+ from ase.vibrations import Vibrations
41
+ from sella import Sella
42
+
43
+ from firecode.algebra import norm, norm_of
44
+ from firecode.graph_manipulations import findPaths, graphize, neighbors
45
+ from firecode.rmsd import get_alignment_matrix
46
+ from firecode.settings import COMMANDS, MEM_GB
47
+ from firecode.solvents import get_solvent_line
48
+ from firecode.utils import (HiddenPrints, align_structures, clean_directory,
49
+ get_double_bonds_indices, molecule_check,
50
+ scramble_check, time_to_string, write_xyz)
51
+
52
+
53
+ class Spring:
54
+ '''
55
+ ASE Custom Constraint Class
56
+ Adds an harmonic force between a pair of atoms.
57
+ Spring constant is very high to achieve tight convergence,
58
+ but maximum force is dampened so as not to ruin structures.
59
+ '''
60
+ def __init__(self, i1, i2, d_eq, k=100):
61
+ self.i1, self.i2 = i1, i2
62
+ self.d_eq = d_eq
63
+ self.k = k
64
+
65
+ def adjust_positions(self, atoms, newpositions):
66
+ pass
67
+
68
+ def adjust_forces(self, atoms, forces):
69
+
70
+ direction = atoms.positions[self.i2] - atoms.positions[self.i1]
71
+ # vector connecting atom1 to atom2
72
+
73
+ spring_force = self.k * (norm_of(direction) - self.d_eq)
74
+ # absolute spring force (float). Positive if spring is overstretched.
75
+
76
+
77
+ spring_force = np.clip(spring_force, -50, 50)
78
+ # force is clipped at 50 eV/A ()
79
+
80
+ forces[self.i1] += (norm(direction) * spring_force)
81
+ forces[self.i2] -= (norm(direction) * spring_force)
82
+ # applying harmonic force to each atom, directed toward the other one
83
+
84
+ def __repr__(self):
85
+ return f'Spring - ids:{self.i1}/{self.i2} - d_eq:{self.d_eq}, k:{self.k}'
86
+
87
+ class HalfSpring:
88
+ '''
89
+ ASE Custom Constraint Class
90
+ Adds an harmonic force between a pair of atoms,
91
+ only if those two atoms are at least d_max
92
+ Angstroms apart.
93
+ '''
94
+ def __init__(self, i1, i2, d_max, k=1000):
95
+ self.i1, self.i2 = i1, i2
96
+ self.d_max = d_max
97
+ self.k = k
98
+
99
+ def adjust_positions(self, atoms, newpositions):
100
+ pass
101
+
102
+ def adjust_forces(self, atoms, forces):
103
+
104
+ direction = atoms.positions[self.i2] - atoms.positions[self.i1]
105
+ # vector connecting atom1 to atom2
106
+
107
+ if norm_of(direction) > self.d_max:
108
+
109
+ spring_force = self.k * (norm_of(direction) - self.d_max)
110
+ # absolute spring force (float). Positive if spring is overstretched.
111
+
112
+ spring_force = np.clip(spring_force, -50, 50)
113
+ # force is clipped at 50 eV/A
114
+
115
+ forces[self.i1] += (norm(direction) * spring_force)
116
+ forces[self.i2] -= (norm(direction) * spring_force)
117
+ # applying harmonic force to each atom, directed toward the other one
118
+
119
+ def __repr__(self):
120
+ return f'Spring - ids:{self.i1}/{self.i2} - d_max:{self.d_max}, k:{self.k}'
121
+
122
+ def get_ase_calc(embedder):
123
+ '''
124
+ Attach the correct ASE calculator
125
+ to the ASE Atoms object.
126
+ embedder: either a firecode embedder object or
127
+ a 4-element strings tuple containing
128
+ (calculator, method, procs, solvent)
129
+ '''
130
+ if isinstance(embedder, tuple):
131
+ calculator, method, procs, solvent = embedder
132
+
133
+ else:
134
+ calculator = embedder.options.calculator
135
+ method = embedder.options.theory_level
136
+ procs = embedder.procs
137
+ solvent = embedder.options.solvent
138
+
139
+ if calculator == 'XTB':
140
+ try:
141
+ from xtb.ase.calculator import XTB
142
+ except ImportError:
143
+ raise Exception(('Cannot import xtb python bindings. Install them with:\n'
144
+ '>>> conda install -c conda-forge xtb-python\n'
145
+ '(See https://github.com/grimme-lab/xtb-python)'))
146
+
147
+ from firecode.solvents import (solvent_synonyms, xtb_solvents,
148
+ xtb_supported)
149
+
150
+ solvent = solvent_synonyms[solvent] if solvent in solvent_synonyms else solvent
151
+ solvent = 'none' if solvent is None else solvent
152
+
153
+ if solvent not in xtb_solvents:
154
+ raise Exception(f'Solvent \'{solvent}\' not supported by XTB. Supported solvents are:\n{xtb_supported}')
155
+
156
+ return XTB(method=method, solvent=solvent)
157
+
158
+
159
+ command = COMMANDS[calculator]
160
+
161
+ if calculator == 'MOPAC':
162
+
163
+ if solvent is not None:
164
+ method = method + ' ' + get_solvent_line(solvent, calculator, method)
165
+
166
+ return MOPAC(label='temp',
167
+ command=f'{command} temp.mop > temp.cmdlog 2>&1',
168
+ method=method+' GEO-OK')
169
+
170
+ if calculator == 'ORCA':
171
+
172
+ orcablocks = ''
173
+
174
+ if procs > 1:
175
+ orcablocks += f'%pal nprocs {procs} end'
176
+
177
+ if solvent is not None:
178
+ orcablocks += get_solvent_line(solvent, calculator, method)
179
+
180
+ return ORCA(label='temp',
181
+ command=f'{command} temp.inp "--oversubscribe" > temp.out 2>&1',
182
+ orcasimpleinput=method,
183
+ orcablocks=orcablocks)
184
+
185
+ if calculator == 'GAUSSIAN':
186
+
187
+ if solvent is not None:
188
+ method = method + ' ' + get_solvent_line(solvent, calculator, method)
189
+
190
+ mem = str(MEM_GB)+'GB' if MEM_GB >= 1 else str(int(1000*MEM_GB))+'MB'
191
+
192
+ calc = Gaussian(label='temp',
193
+ command=f'{command} temp.com',
194
+ method=method,
195
+ nprocshared=procs,
196
+ mem=mem,
197
+ )
198
+
199
+ if 'g09' in command:
200
+
201
+ from ase.io import read
202
+ def g09_read_results(self=calc):
203
+ output = read(self.label + '.out', format='gaussian-out')
204
+ self.calc = output.calc
205
+ self.results = output.calc.results
206
+
207
+ calc.read_results = g09_read_results
208
+
209
+ # Adapting for g09 outputting .out files instead of g16 .log files.
210
+ # This is a bad fix and the issue should be corrected in the ASE
211
+ # source code: merge request on GitHub pending to be written
212
+
213
+ return calc
214
+
215
+ # def ase_adjust_spacings(embedder, structure, atomnos, constrained_indices, title=0, traj=None):
216
+ # '''
217
+ # embedder: firecode embedder object
218
+ # structure: TS candidate coordinates to be adjusted
219
+ # atomnos: 1-d array with element numbering for the TS
220
+ # constrained_indices: (n,2)-shaped array of indices to be distance constrained
221
+ # mols_graphs: list of NetworkX graphs, ordered as the single molecules in the TS
222
+ # title: number to be used for referring to this structure in the embedder log
223
+ # traj: if set to a string, traj+'.traj' is used as a filename for the refinement trajectory.
224
+ # '''
225
+ # atoms = Atoms(atomnos, positions=structure)
226
+
227
+ # atoms.calc = get_ase_calc(embedder)
228
+
229
+ # springs = [Spring(indices[0], indices[1], dist) for indices, dist in embedder.target_distances.items()]
230
+ # # adding springs to adjust the pairings for which we have target distances
231
+
232
+ # # if there are no springs, it is faster (and equivalent) to just do a classical full opitimization
233
+ # if not springs:
234
+ # from firecode.optimization_methods import optimize
235
+ # return optimize(
236
+ # structure,
237
+ # atomnos,
238
+ # embedder.options.calculator,
239
+ # method=embedder.options.theory_level,
240
+ # mols_graphs=embedder.graphs if embedder.embed != 'monomolecular' else None,
241
+ # procs=embedder.procs,
242
+ # solvent=embedder.options.solvent,
243
+ # max_newbonds=embedder.options.max_newbonds,
244
+ # check=(embedder.embed != 'refine'),
245
+
246
+ # logfunction=lambda s: embedder.log(s, p=False),
247
+ # title=f'Candidate_{title}'
248
+ # )
249
+
250
+ # nci_indices = [indices for letter, indices in embedder.pairings_table.items() if letter.islower()]
251
+ # halfsprings = [HalfSpring(i1, i2, 2.5) for i1, i2 in nci_indices]
252
+ # # HalfSprings get atoms involved in NCIs together if they are more than 2.5A apart,
253
+ # # but lets them achieve their natural equilibrium distance when closer
254
+
255
+ # psc = PreventScramblingConstraint(graphize(structure, atomnos),
256
+ # atoms,
257
+ # double_bond_protection=embedder.options.double_bond_protection,
258
+ # fix_angles=embedder.options.fix_angles_in_deformation)
259
+
260
+ # atoms.set_constraint(springs + halfsprings + [psc])
261
+
262
+ # t_start_opt = time.perf_counter()
263
+ # try:
264
+ # with LBFGS(atoms, maxstep=0.2, logfile=None, trajectory=traj) as opt:
265
+
266
+ # opt.run(fmax=0.05, steps=500)
267
+ # # initial coarse refinement with
268
+ # # Springs, Half Springs and PSC
269
+
270
+ # for spring in springs:
271
+ # spring.tighten()
272
+ # atoms.set_constraint(springs)
273
+ # # Tightening Springs to improve
274
+ # # spacings accuracy, removing PSC
275
+ # # spacings accuracy, removing PSC
276
+
277
+ # opt.run(fmax=0.05, steps=200)
278
+ # # final accurate refinement
279
+
280
+ # iterations = opt.nsteps
281
+
282
+
283
+ # new_structure = atoms.get_positions()
284
+
285
+ # success = scramble_check(new_structure, atomnos, constrained_indices, embedder.graphs)
286
+ # if iterations == 200:
287
+ # exit_str = 'MAX ITER'
288
+ # elif success:
289
+ # exit_str = 'REFINED'
290
+ # else:
291
+ # exit_str = 'SCRAMBLED'
292
+
293
+ # if iterations == 200:
294
+ # exit_str = 'MAX ITER'
295
+ # elif success:
296
+ # exit_str = 'REFINED'
297
+ # else:
298
+ # exit_str = 'SCRAMBLED'
299
+
300
+ # except PropertyNotImplementedError:
301
+ # exit_str = 'CRASHED'
302
+
303
+ # embedder.log(f' - {title} {exit_str} ({iterations} iterations, {time_to_string(time.perf_counter()-t_start_opt)})', p=False)
304
+ # embedder.log(f' - {title} {exit_str} ({iterations} iterations, {time_to_string(time.perf_counter()-t_start_opt)})', p=False)
305
+
306
+ # if exit_str == 'CRASHED':
307
+ # return None, None, False
308
+
309
+ # energy = atoms.get_total_energy() * 23.06054194532933 #eV to kcal/mol
310
+
311
+ # return new_structure, energy, success
312
+
313
+ def ase_saddle(embedder, coords, atomnos, constrained_indices=None, mols_graphs=None, title='temp', logfile=None, traj=None, freq=False, maxiterations=200):
314
+ '''
315
+ Runs a first order saddle optimization through the ASE package
316
+ '''
317
+ atoms = Atoms(atomnos, positions=coords)
318
+
319
+ atoms.calc = get_ase_calc(embedder)
320
+
321
+ t_start = time.perf_counter()
322
+ with HiddenPrints():
323
+ with Sella(atoms,
324
+ logfile=logfile,
325
+ order=1,
326
+ trajectory=traj) as opt:
327
+
328
+ opt.run(fmax=0.05, steps=maxiterations)
329
+ iterations = opt.nsteps
330
+
331
+ if logfile is not None:
332
+ t_end_berny = time.perf_counter()
333
+ elapsed = t_end_berny - t_start
334
+ exit_str = 'converged' if iterations < maxiterations else 'stopped'
335
+ logfile.write(f'{title} - {exit_str} in {iterations} steps ({time_to_string(elapsed)})\n')
336
+
337
+ new_structure = atoms.get_positions()
338
+ energy = atoms.get_total_energy() * 23.06054194532933 #eV to kcal/mol
339
+
340
+ if mols_graphs is not None:
341
+ success = scramble_check(new_structure, atomnos, constrained_indices, mols_graphs, max_newbonds=embedder.options.max_newbonds)
342
+ else:
343
+ success = molecule_check(coords, new_structure, atomnos, max_newbonds=embedder.options.max_newbonds)
344
+
345
+ return new_structure, energy, success
346
+
347
+ def ase_vib(embedder, coords, atomnos, logfunction=None, title='temp'):
348
+ '''
349
+ Calculate frequencies through ASE - returns frequencies and number of negatives (not in use)
350
+ '''
351
+ atoms = Atoms(atomnos, positions=coords)
352
+ atoms.calc = get_ase_calc(embedder)
353
+ vib = Vibrations(atoms, name=title)
354
+
355
+ if os.path.isdir(title):
356
+ os.chdir(title)
357
+ for f in os.listdir():
358
+ os.remove(f)
359
+ os.chdir(os.path.dirname(os.getcwd()))
360
+ else:
361
+ os.mkdir(title)
362
+
363
+ os.chdir(title)
364
+
365
+ t_start = time.perf_counter()
366
+
367
+ with HiddenPrints():
368
+ vib.run()
369
+
370
+ # freqs = vib.get_frequencies()
371
+ freqs = vib.get_energies()* 8065.544 # from eV to cm-1
372
+
373
+ if logfunction is not None:
374
+ elapsed = time.perf_counter() - t_start
375
+ logfunction(f'{title} - frequency calculation completed ({time_to_string(elapsed)})')
376
+
377
+ os.chdir(os.path.dirname(os.getcwd()))
378
+
379
+ return freqs, np.count_nonzero(freqs.imag > 1e-3)
380
+
381
+ def ase_neb(embedder, reagents, products, atomnos, ts_guess=None, n_images=6, mep_override=None, title='temp', optimizer=LBFGS, logfunction=None, write_plot=False, verbose_print=False):
382
+ '''
383
+ embedder: firecode embedder object
384
+ reagents: coordinates for the atom arrangement to be used as reagents
385
+ products: coordinates for the atom arrangement to be used as products
386
+ atomnos: 1-d array of atomic numbers
387
+ n_images: number of optimized images connecting reag/prods
388
+ title: name used to write the final MEP as a .xyz file
389
+ optimizer: ASE optimizer to be used in
390
+ logfile: filename to dump the optimization data to. If None, no file is written.
391
+
392
+ return: 3- element tuple with coodinates of highest point along the MEP, its
393
+ energy in kcal/mol and a boolean value indicating success.
394
+ '''
395
+ reagents, products = align_structures(np.array([reagents, products]))
396
+ first = Atoms(atomnos, positions=reagents)
397
+ last = Atoms(atomnos, positions=products)
398
+
399
+ if mep_override is not None:
400
+
401
+ images = [Atoms(atomnos, positions=coords) for coords in mep_override]
402
+ neb = DyNEB(images, fmax=0.05, climb=False, method='eb', scale_fmax=1, allow_shared_calculator=True)
403
+
404
+ elif ts_guess is None:
405
+ images = [first]
406
+ images += [first.copy() for _ in range(n_images)]
407
+ images += [last]
408
+
409
+ neb = DyNEB(images, fmax=0.05, climb=False, method='eb', scale_fmax=1, allow_shared_calculator=True)
410
+ neb.interpolate(method='idpp')
411
+
412
+ else:
413
+ ts_guess = Atoms(atomnos, positions=ts_guess)
414
+
415
+ images_1 = [first] + [first.copy() for _ in range(round((n_images-3)/2))] + [ts_guess]
416
+ interp_1 = DyNEB(images_1)
417
+ interp_1.interpolate(method='idpp')
418
+
419
+ images_2 = [ts_guess] + [last.copy() for _ in range(n_images-len(interp_1.images)-1)] + [last]
420
+ interp_2 = DyNEB(images_2)
421
+ interp_2.interpolate(method='idpp')
422
+
423
+ images = interp_1.images + interp_2.images[1:]
424
+
425
+ neb = DyNEB(images, fmax=0.05, climb=False, method='eb', scale_fmax=1, allow_shared_calculator=True)
426
+
427
+ if mep_override is None:
428
+ ase_dump(f'{title}_MEP_guess.xyz', images, atomnos)
429
+
430
+ if verbose_print and logfunction is not None and mep_override is None:
431
+ logfunction(f'\n\n--> Saved interpolated MEP guess to {title}_MEP_guess.xyz\n')
432
+
433
+ # Set calculators for all images
434
+ for _, image in enumerate(images):
435
+ image.calc = get_ase_calc(embedder)
436
+
437
+ t_start = time.perf_counter()
438
+
439
+ # Set the optimizer and optimize
440
+ try:
441
+
442
+ with warnings.catch_warnings():
443
+ warnings.simplefilter('ignore')
444
+ # ignore runtime warnings from the NEB module:
445
+ # if something went wrong, we will deal with it later
446
+
447
+ with optimizer(neb, maxstep=0.1, logfile=None if not verbose_print else 'neb_opt.log') as opt:
448
+
449
+ if verbose_print and logfunction is not None:
450
+ logfunction(f'\n--> Running NEB-CI through ASE ({embedder.options.theory_level} via {embedder.options.calculator})')
451
+
452
+ opt.run(fmax=0.05, steps=20)
453
+ while neb.get_residual() > 0.1:
454
+ opt.run(fmax=0.05, steps=10+opt.nsteps)
455
+ # some free relaxation before starting to climb
456
+ if opt.nsteps > 500 or opt.converged:
457
+ break
458
+
459
+ if verbose_print and logfunction is not None:
460
+ logfunction('--> fmax below 0.1: Activated Climbing Image and smaller maxstep')
461
+
462
+ ase_dump(f'{title}_MEP_start_of_CI.xyz', neb.images, atomnos)
463
+
464
+ optimizer.maxstep = 0.01
465
+ neb.climb = True
466
+
467
+ opt.run(fmax=0.05, steps=250+opt.nsteps)
468
+
469
+ iterations = opt.nsteps
470
+ exit_status = 'CONVERGED' if iterations < 279 else 'MAX ITER'
471
+
472
+ # success = True if exit_status == 'CONVERGED' else False
473
+
474
+ except (PropertyNotImplementedError, CalculationFailed):
475
+ if logfunction is not None:
476
+ logfunction(f' - NEB for {title} CRASHED ({time_to_string(time.perf_counter()-t_start)})\n')
477
+ try:
478
+ ase_dump(f'{title}_MEP_crashed.xyz', neb.images, atomnos)
479
+ except Exception():
480
+ pass
481
+ return None, None, None, False
482
+
483
+ except KeyboardInterrupt:
484
+ exit_status = 'ABORTED BY USER'
485
+
486
+ if logfunction is not None:
487
+ logfunction(f' - NEB for {title} {exit_status} ({time_to_string(time.perf_counter()-t_start)})\n')
488
+
489
+ energies = [image.get_total_energy() * 23.06054194532933 for image in images] # eV to kcal/mol
490
+
491
+ ts_id = energies.index(max(energies))
492
+ # print(f'TS structure is number {ts_id}, energy is {max(energies)}')
493
+
494
+ if mep_override is None:
495
+ os.remove(f'{title}_MEP_guess.xyz')
496
+ ase_dump(f'{title}_MEP.xyz', images, atomnos, energies)
497
+ # Save the converged MEP (minimum energy path) to an .xyz file
498
+
499
+ if write_plot:
500
+
501
+ plt.figure()
502
+ plt.plot(
503
+ range(1,len(images)+1),
504
+ np.array(energies)-min(energies),
505
+ color='tab:blue',
506
+ label='Image energies',
507
+ linewidth=3,
508
+ )
509
+
510
+ plt.plot(
511
+ [ts_id+1],
512
+ [energies[ts_id]-min(energies)],
513
+ color='gold',
514
+ label='TS guess',
515
+ marker='o',
516
+ markersize=3,
517
+ )
518
+
519
+ plt.legend()
520
+ plt.title(title)
521
+ plt.xlabel('Image number')
522
+ plt.ylabel('Rel. E. (kcal/mol)')
523
+ plt.savefig(f'{title.replace(" ", "_")}_plt.svg')
524
+
525
+ return images[ts_id].get_positions(), energies[ts_id], energies, exit_status
526
+
527
+ class OrbitalSpring:
528
+ '''
529
+ ASE Custom Constraint Class
530
+ Adds a series of forces based on a pair of orbitals, that is
531
+ virtual points "bonded" to a given atom.
532
+
533
+ :params i1, i2: indices of reactive atoms
534
+ :params orb1, orb2: 3D coordinates of orbitals
535
+ :params neighbors_of_1, neighbors_of_2: lists of indices for atoms bonded to i1/i2
536
+ :params d_eq: equilibrium target distance between orbital centers
537
+ '''
538
+ def __init__(self, i1, i2, orb1, orb2, neighbors_of_1, neighbors_of_2, d_eq, k=1000):
539
+ self.i1, self.i2 = i1, i2
540
+ self.orb1, self.orb2 = orb1, orb2
541
+ self.neighbors_of_1, self.neighbors_of_2 = neighbors_of_1, neighbors_of_2
542
+ self.d_eq = d_eq
543
+ self.k = k
544
+
545
+ def adjust_positions(self, atoms, newpositions):
546
+ pass
547
+
548
+ def adjust_forces(self, atoms, forces):
549
+
550
+ # First, assess if we have to move atoms 1 and 2 at all
551
+
552
+ sum_of_distances = (norm_of(atoms.positions[self.i1] - self.orb1) +
553
+ norm_of(atoms.positions[self.i2] - self.orb2) + self.d_eq)
554
+
555
+ reactive_atoms_distance = norm_of(atoms.positions[self.i1] - atoms.positions[self.i2])
556
+
557
+ orb_direction = self.orb2 - self.orb1
558
+ # vector connecting orb1 to orb2
559
+
560
+ spring_force = self.k * (norm_of(orb_direction) - self.d_eq)
561
+ # absolute spring force (float). Positive if spring is overstretched.
562
+
563
+ # spring_force = np.clip(spring_force, -50, 50)
564
+ # # force is clipped at 5 eV/A
565
+
566
+ force_direction1 = np.sign(spring_force) * norm(np.mean((norm(+orb_direction),
567
+ norm(self.orb1-atoms.positions[self.i1])), axis=0))
568
+
569
+ force_direction2 = np.sign(spring_force) * norm(np.mean((norm(-orb_direction),
570
+ norm(self.orb2-atoms.positions[self.i2])), axis=0))
571
+
572
+ # versors specifying the direction at which forces act, that is on the
573
+ # bisector of the angle between vector connecting atom to orbital and
574
+ # vector connecting the two orbitals
575
+
576
+ if np.abs(sum_of_distances - reactive_atoms_distance) > 0.2:
577
+
578
+ forces[self.i1] += (force_direction1 * spring_force)
579
+ forces[self.i2] += (force_direction2 * spring_force)
580
+ # applying harmonic force to each atom, directed toward the other one
581
+
582
+ # Now applying to neighbors the force derived by torque, scaled to match the spring_force,
583
+ # but only if atomic orbitals are more than two Angstroms apart. This improves convergence.
584
+
585
+ if norm_of(orb_direction) > 2:
586
+ torque1 = np.cross(self.orb1 - atoms.positions[self.i1], force_direction1)
587
+ for i in self.neighbors_of_1:
588
+ forces[i] += norm(np.cross(torque1, atoms.positions[i] - atoms.positions[self.i1])) * spring_force
589
+
590
+ torque2 = np.cross(self.orb2 - atoms.positions[self.i2], force_direction2)
591
+ for i in self.neighbors_of_2:
592
+ forces[i] += norm(np.cross(torque2, atoms.positions[i] - atoms.positions[self.i2])) * spring_force
593
+
594
+ def PreventScramblingConstraint(graph, atoms, double_bond_protection=False, fix_angles=False):
595
+ '''
596
+ graph: NetworkX graph of the molecule
597
+ atoms: ASE atoms object
598
+
599
+ return: FixInternals constraint to apply to ASE calculations
600
+ '''
601
+ angles_deg = None
602
+ if fix_angles:
603
+ allpaths = []
604
+
605
+ for node in graph:
606
+ allpaths.extend(findPaths(graph, node, 2))
607
+
608
+ allpaths = {tuple(sorted(path)) for path in allpaths}
609
+
610
+ angles_deg = []
611
+ for path in allpaths:
612
+ angles_deg.append([atoms.get_angle(*path), list(path)])
613
+
614
+ bonds = []
615
+ for bond in [[a, b] for a, b in graph.edges if a != b]:
616
+ bonds.append([atoms.get_distance(*bond), bond])
617
+
618
+ dihedrals_deg = None
619
+ if double_bond_protection:
620
+ double_bonds = get_double_bonds_indices(atoms.positions, atoms.get_atomic_numbers())
621
+ if double_bonds != []:
622
+ dihedrals_deg = []
623
+ for a, b in double_bonds:
624
+ n_a = neighbors(graph, a)
625
+ n_a.remove(b)
626
+
627
+ n_b = neighbors(graph, b)
628
+ n_b.remove(a)
629
+
630
+ d = [n_a[0], a, b, n_b[0]]
631
+ dihedrals_deg.append([atoms.get_dihedral(*d), d])
632
+
633
+ return FixInternals(dihedrals_deg=dihedrals_deg, angles_deg=angles_deg, bonds=bonds, epsilon=1)
634
+
635
+ def ase_popt(embedder, coords, atomnos, constrained_indices=None,
636
+ steps=500, targets=None, safe=False, safe_mask=None,
637
+ traj=None, logfunction=None, title='temp'):
638
+ '''
639
+ embedder: firecode embedder object
640
+ coords:
641
+ atomnos:
642
+ constrained_indices:
643
+ safe: if True, adds a potential that prevents atoms from scrambling
644
+ safe_mask: bool array, with False for atoms to be excluded when calculating bonds to preserve
645
+ traj: if set to a string, traj is used as a filename for the bending trajectory.
646
+ not only the atoms will be printed, but also all the orbitals and the active pivot.
647
+ '''
648
+ atoms = Atoms(atomnos, positions=coords)
649
+ atoms.calc = get_ase_calc(embedder)
650
+ constraints = []
651
+
652
+ if constrained_indices is not None:
653
+ for i, c in enumerate(constrained_indices):
654
+ i1, i2 = c
655
+ tgt_dist = norm_of(coords[i1]-coords[i2]) if targets is None else targets[i]
656
+ constraints.append(Spring(i1, i2, tgt_dist))
657
+
658
+ if safe:
659
+ constraints.append(PreventScramblingConstraint(graphize(coords, atomnos, safe_mask),
660
+ atoms,
661
+ double_bond_protection=embedder.options.double_bond_protection,
662
+ fix_angles=embedder.options.fix_angles_in_deformation))
663
+
664
+ atoms.set_constraint(constraints)
665
+
666
+ t_start_opt = time.perf_counter()
667
+ with LBFGS(atoms, maxstep=0.1, logfile=None, trajectory=traj) as opt:
668
+ opt.run(fmax=0.05, steps=steps)
669
+ iterations = opt.nsteps
670
+
671
+ new_structure = atoms.get_positions()
672
+ success = (iterations < 499)
673
+
674
+ if logfunction is not None:
675
+ exit_str = 'REFINED' if success else 'MAX ITER'
676
+ logfunction(f' - {title} {exit_str} ({iterations} iterations, {time_to_string(time.perf_counter()-t_start_opt)})')
677
+
678
+ energy = atoms.get_total_energy() * 23.06054194532933 #eV to kcal/mol
679
+
680
+ return new_structure, energy, success
681
+
682
+ def ase_bend(embedder, original_mol, conf, pivot, threshold, title='temp', traj=None, check=True):
683
+ '''
684
+ embedder: firecode embedder object
685
+ original_mol: Hypermolecule object to be bent
686
+ conf: index of conformation in original_mol to be used
687
+ pivot: pivot connecting two Hypermolecule orbitals to be approached/distanced
688
+ threshold: target distance for the specified pivot, in Angstroms
689
+ title: name to be used for referring to this structure in the embedder log
690
+ traj: if set to a string, traj+\'.traj\' is used as a filename for the bending trajectory.
691
+ not only the atoms will be printed, but also all the orbitals and the active pivot.
692
+ check: if True, after bending checks that the bent structure did not scramble.
693
+ If it did, returns the initial molecule.
694
+ '''
695
+
696
+ identifier = np.sum(original_mol.atomcoords[conf])
697
+
698
+ if hasattr(embedder, "ase_bent_mols_dict"):
699
+ cached = embedder.ase_bent_mols_dict.get((identifier, tuple(sorted(pivot.index)), round(threshold, 3)))
700
+ if cached is not None:
701
+ return cached
702
+
703
+ if traj is not None:
704
+
705
+ from ase.io.trajectory import Trajectory
706
+
707
+ def orbitalized(atoms, orbitals, pivot=None):
708
+ positions = np.concatenate((atoms.positions, orbitals))
709
+
710
+ if pivot is not None:
711
+ positions = np.concatenate((positions, [pivot.start], [pivot.end]))
712
+
713
+ symbols = list(atoms.numbers) + [0 for _ in orbitals]
714
+
715
+ if pivot is not None:
716
+ symbols += [9 for _ in range(2)]
717
+ # Fluorine (9) represents active orbitals
718
+
719
+ new_atoms = Atoms(symbols, positions=positions)
720
+ return new_atoms
721
+
722
+ try:
723
+ os.remove(traj)
724
+ except FileNotFoundError:
725
+ pass
726
+
727
+ i1, i2 = original_mol.reactive_indices
728
+
729
+ neighbors_of_1 = neighbors(original_mol.graph, i1)
730
+ neighbors_of_2 = neighbors(original_mol.graph, i2)
731
+
732
+ mol = deepcopy(original_mol)
733
+ final_mol = deepcopy(original_mol)
734
+
735
+ for p in mol.pivots[conf]:
736
+ if p.index == pivot.index:
737
+ active_pivot = p
738
+ break
739
+
740
+ dist = norm_of(active_pivot.pivot)
741
+
742
+ atoms = Atoms(mol.atomnos, positions=mol.atomcoords[conf])
743
+
744
+ atoms.calc = get_ase_calc(embedder)
745
+
746
+ if traj is not None:
747
+ traj_obj = Trajectory(traj + f'_conf{conf}.traj',
748
+ mode='a',
749
+ atoms=orbitalized(atoms,
750
+ np.vstack([atom.center for atom in mol.reactive_atoms_classes_dict[0].values()]),
751
+ active_pivot))
752
+ traj_obj.write()
753
+
754
+ unproductive_iterations = 0
755
+ break_reason = 'MAX ITER'
756
+ t_start = time.perf_counter()
757
+
758
+ for iteration in range(500):
759
+
760
+ atoms.positions = mol.atomcoords[0]
761
+
762
+ orb_memo = {index:norm_of(atom.center[0]-atom.coord) for index, atom in mol.reactive_atoms_classes_dict[0].items()}
763
+
764
+ orb1, orb2 = active_pivot.start, active_pivot.end
765
+
766
+ c1 = OrbitalSpring(i1, i2, orb1, orb2, neighbors_of_1, neighbors_of_2, d_eq=threshold)
767
+
768
+ c2 = PreventScramblingConstraint(mol.graph,
769
+ atoms,
770
+ double_bond_protection=embedder.options.double_bond_protection,
771
+ fix_angles=embedder.options.fix_angles_in_deformation)
772
+
773
+ atoms.set_constraint([
774
+ c1,
775
+ c2,
776
+ ])
777
+
778
+ opt = BFGS(atoms, maxstep=0.2, logfile=None, trajectory=None)
779
+
780
+ try:
781
+ opt.run(fmax=0.5, steps=1)
782
+ except ValueError:
783
+ # Shake did not converge
784
+ break_reason = 'CRASHED'
785
+ break
786
+
787
+ if traj is not None:
788
+ traj_obj.atoms = orbitalized(atoms, np.vstack([atom.center for atom in mol.reactive_atoms_classes_dict[0].values()]))
789
+ traj_obj.write()
790
+
791
+ # check if we are stuck
792
+ if np.max(np.abs(np.linalg.norm(atoms.get_positions() - mol.atomcoords[0], axis=1))) < 0.01:
793
+ unproductive_iterations += 1
794
+
795
+ if unproductive_iterations == 10:
796
+ break_reason = 'STUCK'
797
+ break
798
+
799
+ else:
800
+ unproductive_iterations = 0
801
+
802
+ mol.atomcoords[0] = atoms.get_positions()
803
+
804
+ # Update orbitals and get temp pivots
805
+ for index, atom in mol.reactive_atoms_classes_dict[0].items():
806
+ atom.init(mol, index, update=True, orb_dim=orb_memo[index])
807
+ # orbitals positions are calculated based on the conformer we are working on
808
+
809
+ temp_pivots = embedder._get_pivots(mol)[0]
810
+
811
+ for p in temp_pivots:
812
+ if p.index == pivot.index:
813
+ active_pivot = p
814
+ break
815
+ # print(active_pivot)
816
+
817
+ dist = norm_of(active_pivot.pivot)
818
+ # print(f'{iteration}. {mol.filename} conf {conf}: pivot is {round(dist, 3)} (target {round(threshold, 3)})')
819
+
820
+ if dist - threshold < 0.1:
821
+ break_reason = 'CONVERGED'
822
+ break
823
+ # else:
824
+ # print('delta is ', round(dist - threshold, 3))
825
+
826
+ embedder.log(f' {title} - conformer {conf} - {break_reason}{" "*(9-len(break_reason))} ({iteration+1}{" "*(3-len(str(iteration+1)))} iterations, {time_to_string(time.perf_counter()-t_start)})', p=False)
827
+
828
+ if check:
829
+ if not molecule_check(original_mol.atomcoords[conf], mol.atomcoords[0], mol.atomnos, max_newbonds=1):
830
+ mol.atomcoords[0] = original_mol.atomcoords[conf]
831
+ # keep the bent structures only if no scrambling occurred between atoms
832
+
833
+ final_mol.atomcoords[conf] = mol.atomcoords[0]
834
+
835
+ # Now align the ensembles on the new reactive atoms positions
836
+
837
+ reference, *targets = final_mol.atomcoords
838
+ reference = np.array(reference)
839
+ targets = np.array(targets)
840
+
841
+ r = reference - np.mean(reference[final_mol.reactive_indices], axis=0)
842
+ ts = np.array([t - np.mean(t[final_mol.reactive_indices], axis=0) for t in targets])
843
+
844
+ output = []
845
+ output.append(r)
846
+ for target in ts:
847
+ matrix = get_alignment_matrix(r, target)
848
+ output.append([matrix @ vector for vector in target])
849
+
850
+ final_mol.atomcoords = np.array(output)
851
+
852
+ # Update orbitals and pivots
853
+ for conf_, _ in enumerate(final_mol.atomcoords):
854
+ for index, atom in final_mol.reactive_atoms_classes_dict[conf_].items():
855
+ atom.init(final_mol, index, update=True, orb_dim=orb_memo[index])
856
+
857
+ embedder._set_pivots(final_mol)
858
+
859
+ # add result to cache (if we have it) so we avoid recomputing it
860
+ if hasattr(embedder, "ase_bent_mols_dict"):
861
+ embedder.ase_bent_mols_dict[(identifier, tuple(sorted(pivot.index)), round(threshold, 3))] = final_mol
862
+
863
+ clean_directory()
864
+
865
+ return final_mol
866
+
867
+ def ase_dump(filename, images, atomnos, energies=None):
868
+
869
+ if energies is None:
870
+ energies = ["" for _ in images]
871
+ else:
872
+ energies = np.array(energies)
873
+ energies -= np.min(energies)
874
+
875
+ with open(filename, 'w') as f:
876
+ for i, (image, energy) in enumerate(zip(images, energies)):
877
+ e = f" Rel.E = {round(energy, 3)} kcal/mol" if energy != "" else ""
878
+ coords = image.get_positions()
879
+ write_xyz(coords, atomnos, f, title=f'STEP {i+1} - {filename[:-4]}_image_{i+1}{e}')