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,186 @@
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
+ def run_setup():
25
+ '''
26
+ Invoked by the command
27
+ > python -m firecode -s (--setup)
28
+
29
+ Guides the user in setting up the calculation options
30
+ contained in the settings.py file.
31
+ '''
32
+
33
+ import os
34
+ os.chdir(os.path.dirname(os.path.realpath(__file__)))
35
+ from firecode.settings import DEFAULT_LEVELS, DEFAULT_FF_LEVELS, COMMANDS
36
+
37
+ properties = {
38
+ 'FF_OPT_BOOL':False,
39
+ 'FF_CALC':None,
40
+ 'NEW_FF_DEFAULT':None,
41
+ 'CALCULATOR':None,
42
+ 'NEW_DEFAULT':None,
43
+ 'NEW_COMMAND':None,
44
+ 'PROCS':1,
45
+ 'MEM_GB':1,
46
+ }
47
+
48
+ def ask(text, accepted=('y','n'), default='n'):
49
+ text = '--> ' + text
50
+ if accepted is None:
51
+ answer = input(text)
52
+ print()
53
+ return answer
54
+ answer = None
55
+ while answer not in accepted:
56
+ answer = input(text)
57
+ answer = answer if answer != '' else default
58
+ print()
59
+ return answer
60
+
61
+ tag_dict = {
62
+ 'mop':'MOPAC',
63
+ 'orca':'ORCA',
64
+ 'gau':'GAUSSIAN',
65
+ 'xtb':'XTB',
66
+ }
67
+
68
+ print('\FIRECODE setup:\n')
69
+
70
+ #########################################################################################
71
+
72
+ answer = ask('What Force Field calculator would you like to use?\n- XTB -> xtb\n- Gaussian -> gau\n'
73
+ '- None -> none\n\nAnswer [xtb]/gau/none: ', accepted=('xtb', 'gau', 'none'), default='xtb')
74
+
75
+ if answer == 'xtb':
76
+ properties['FF_OPT_BOOL'] = True
77
+ properties['FF_CALC'] = 'XTB'
78
+
79
+ elif answer != 'none':
80
+ properties['FF_OPT_BOOL'] = True
81
+ properties['FF_CALC'] = tag_dict[answer]
82
+
83
+ answer = ask((f'The default level for {properties["FF_CALC"]} force field calculations is \'{DEFAULT_FF_LEVELS[properties["FF_CALC"]]}\'. ' +
84
+ 'If you would like to change it, type it here, otherwise press enter : '), accepted=None)
85
+ if answer != '':
86
+ properties['NEW_FF_DEFAULT'] = answer
87
+
88
+ #########################################################################################
89
+
90
+ answer = ask('What main calculator would you like to use?\n- MOPAC -> mop\n- ORCA -> orca\n- Gaussian -> gau\n- XTB -> xtb\n\nAnswer mop/orca/gau/[xtb]: ',
91
+ accepted=('mop','orca','gau','xtb'), default='xtb')
92
+
93
+ properties['CALCULATOR'] = tag_dict[answer]
94
+
95
+ #########################################################################################
96
+
97
+ answer = ask((f'The default level for {properties["CALCULATOR"]} calculations is \'{DEFAULT_LEVELS[properties["CALCULATOR"]]}\'. ' +
98
+ 'If you would like to change it, type it here, otherwise press enter : '), accepted=None)
99
+ if answer != '':
100
+ properties['NEW_DEFAULT'] = answer
101
+
102
+ #########################################################################################
103
+
104
+ if properties['CALCULATOR'] != 'XTB':
105
+ answer = ask((f'Current command to call {properties["CALCULATOR"]} is {COMMANDS[properties["CALCULATOR"]]}. ' +
106
+ 'If you would like to change it, type it here, otherwise press enter : '), accepted=None)
107
+ if answer != '':
108
+ properties['NEW_COMMAND'] = answer
109
+
110
+ #########################################################################################
111
+
112
+ properties['PROCS'] = ask(f'How many cores should {properties['CALCULATOR']} jobs run on? [4] : ',
113
+ accepted=[str(n) for n in range(1,100)], default=4)
114
+
115
+ #########################################################################################
116
+
117
+ # if properties['CALCULATOR'] in ('GAUSSIAN', 'ORCA'):
118
+ properties['MEM_GB'] = int(ask('How much memory per core should a GAUSSIAN/ORCA job have, in GBs? [4] : ',
119
+ accepted=[str(n) for n in range(1,1000)], default='4'))
120
+
121
+ #########################################################################################
122
+
123
+ rank = {
124
+ 'MOPAC':1,
125
+ 'ORCA':2,
126
+ 'GAUSSIAN':3,
127
+ 'XTB':4,
128
+ }
129
+
130
+ q = "\'"
131
+
132
+ with open('settings.py', 'r') as f:
133
+ lines = f.readlines()
134
+
135
+ old_lines = lines.copy()
136
+
137
+ for _l, line in enumerate(old_lines):
138
+
139
+ if 'FF_OPT_BOOL =' in line:
140
+ lines[_l] = 'FF_OPT_BOOL = ' + str(properties['FF_OPT_BOOL']) + '\n'
141
+ FF_OPT_BOOL = properties['FF_OPT_BOOL']
142
+
143
+ if 'FF_CALC =' in line:
144
+ lines[_l] = 'FF_CALC = ' + q + str(properties['FF_CALC']) + q + '\n'
145
+ FF_CALC = properties['FF_CALC']
146
+
147
+ elif 'CALCULATOR =' in line:
148
+ lines[_l] = 'CALCULATOR = ' + q + properties['CALCULATOR'] + q + '\n'
149
+ CALCULATOR = properties['CALCULATOR']
150
+
151
+ elif 'DEFAULT_LEVELS = {' in line:
152
+ if properties['NEW_DEFAULT'] is not None:
153
+ lines[_l+rank[properties['CALCULATOR']]] = ' '*4 + q + properties['CALCULATOR'] + q + ':' + q + properties['NEW_DEFAULT'] + q + ',\n'
154
+ DEFAULT_LEVELS[CALCULATOR] = properties['NEW_DEFAULT']
155
+
156
+ elif 'DEFAULT_FF_LEVELS = {' in line:
157
+ if properties['NEW_FF_DEFAULT'] is not None:
158
+ lines[_l+rank[properties['FF_CALC']]] = ' '*4 + q + properties['FF_CALC'] + q + ':' + q + properties['NEW_FF_DEFAULT'] + q + ',\n'
159
+ DEFAULT_FF_LEVELS[FF_CALC] = properties['NEW_FF_DEFAULT']
160
+
161
+ elif 'COMMANDS = {' in line:
162
+ if properties['NEW_COMMAND'] is not None:
163
+ lines[_l+rank[properties['CALCULATOR']]] = ' '*4 + q + properties['CALCULATOR'] + q + ':' + q + properties['NEW_COMMAND'] + q + ',\n'
164
+
165
+ elif 'PROCS =' in line:
166
+ lines[_l] = 'PROCS = ' + str(properties['PROCS']) + '\n'
167
+ PROCS = properties['PROCS']
168
+
169
+ elif 'MEM_GB =' in line:
170
+ lines[_l] = 'MEM_GB = ' + str(properties['MEM_GB']) + '\n'
171
+ MEM_GB = properties['MEM_GB']
172
+
173
+ with open('settings.py', 'w') as f:
174
+ f.write(''.join(lines))
175
+
176
+ print('\nfirecode setup performed correctly.')
177
+
178
+ ff = f'{FF_CALC}/{DEFAULT_FF_LEVELS[FF_CALC]}' if FF_OPT_BOOL else 'Turned off'
179
+ opt = f'{CALCULATOR}/{DEFAULT_LEVELS[CALCULATOR]}'
180
+ s = f' FF : {ff}\n OPT : {opt}\n PROCS : {PROCS}'
181
+ s += f'\n MEM : {MEM_GB} GB'
182
+
183
+ print(s)
184
+
185
+ if __name__ == '__main__':
186
+ run_setup()
firecode/mprof.py ADDED
@@ -0,0 +1,65 @@
1
+ if __name__ == '__main__':
2
+ # ## Let's use malloc to see if we have a memory leak
3
+
4
+ # %%
5
+
6
+ from firecode.embedder import Embedder
7
+
8
+ embedder = Embedder(r'C:\Users\Nik\Desktop\debug\malloc\input', stamp='debug')
9
+ # embedder = Embedder(r'/mnt/c/Users/Nik/Desktop/debug/malloc/input', stamp='debug')
10
+ embedder.objects[0].atomcoords.shape
11
+
12
+ # %%
13
+ # does not seem to leak in the conformational search, maybe it does in the augmentation part
14
+ from firecode.embedder import RunEmbedding
15
+ import numpy as np
16
+ mol = embedder.objects[0]
17
+
18
+ # embedder.options.ff_calc = 'XTB'
19
+ # embedder.options.ff_level = 'GFN-FF'
20
+
21
+ re = RunEmbedding(embedder)
22
+ re.structures = np.array(embedder.objects[0].atomcoords)
23
+ re.atomnos = mol.atomnos
24
+ re.constrained_indices = np.array([[] for _ in re.structures])
25
+ re.energies = np.array([0 ,0])
26
+
27
+ re.structures.shape
28
+
29
+ # %%
30
+
31
+ # re.csearch_augmentation(text='warmup', max_structs=100)
32
+
33
+ # # %%
34
+ # tracemalloc.start()
35
+ # s = tracemalloc.take_snapshot()
36
+
37
+ # ############################################################
38
+
39
+ # try:
40
+ # # re.csearch_augmentation(text='tracemalloc', max_structs=100)
41
+ # re.csearch_augmentation_routine()
42
+ # except KeyboardInterrupt:
43
+ # pass
44
+
45
+ # ############################################################
46
+
47
+ # lines = []
48
+ # top_stats = tracemalloc.take_snapshot().compare_to(s, 'lineno')
49
+ # for stat in top_stats[:5]:
50
+ # lines.append(str(stat))
51
+ # print("\n".join(lines))
52
+
53
+
54
+ # %%
55
+ # from memory_profiler import profile
56
+
57
+ # @profile
58
+ def wrapper(*args, **kwargs):
59
+ return re.csearch_augmentation_routine()
60
+
61
+ try:
62
+ # re.csearch_augmentation(text='tracemalloc', max_structs=100)
63
+ wrapper()
64
+ except KeyboardInterrupt:
65
+ pass
firecode/multiembed.py ADDED
@@ -0,0 +1,148 @@
1
+ import os
2
+ import time
3
+ from concurrent.futures import ProcessPoolExecutor, as_completed
4
+ from itertools import permutations
5
+ from shutil import copy, rmtree
6
+
7
+ import numpy as np
8
+
9
+ from firecode.errors import InputError, ZeroCandidatesError
10
+ from firecode.utils import (cartesian_product, suppress_stdout_stderr,
11
+ time_to_string, timing_wrapper)
12
+
13
+
14
+ def multiembed_dispatcher(embedder):
15
+ '''
16
+ Calls the appropriate multiembed subfunction
17
+ based on embedder attributes.
18
+ '''
19
+
20
+ if len(embedder.objects) == 2:
21
+ return multiembed_bifunctional(embedder)
22
+
23
+ raise InputError('The multiembed requested is currently unavailable.')
24
+
25
+
26
+ def multiembed_bifunctional(embedder):
27
+ '''
28
+ Run multiple concurrent bifunctional cyclical embeds
29
+ exploring all relative arrangement of each pair of
30
+ reactive_indices between the two molecules.
31
+ '''
32
+
33
+ mol1, mol2 = embedder.objects
34
+
35
+ # get every possible combination of indices in the two molecules
36
+ pairs = cartesian_product(mol1.reactive_indices, mol2.reactive_indices)
37
+
38
+ # get every arrangement of interacting pairs not insisting on the same atom twice
39
+ arrangements = [((ix_1, ix_2), (iy_1, iy_2)) for ((ix_1, ix_2), (iy_1, iy_2)) in permutations(pairs, 2) if ix_1 != iy_1 and ix_2 != iy_2]
40
+
41
+ structures_out, constr_ids, processes = [], [], []
42
+
43
+ embedder.t_start_run = time.perf_counter()
44
+ embedder.log()
45
+
46
+ max_workers = embedder.avail_cpus or 1
47
+ embedder.log(f'--> Multiembed: running {len(arrangements)} embeds on {max_workers} threads')
48
+
49
+ with ProcessPoolExecutor(max_workers=max_workers) as executor:
50
+
51
+ # for each arrangement, perform a dedicated embed
52
+ for i, arrangement in enumerate(arrangements):
53
+
54
+ process = executor.submit(
55
+ timing_wrapper,
56
+ run_child_embedder,
57
+ mol1.filename,
58
+ mol2.filename,
59
+ constrained_indices=arrangement,
60
+ i=i,
61
+ options=embedder.options,
62
+ )
63
+ processes.append(process)
64
+
65
+ for i, process in enumerate(as_completed(processes)):
66
+
67
+ (structures, constrained_indices), elapsed = process.result()
68
+
69
+ embedder.log(f'--> Child process {i+1:3}/{len(arrangements):3}: generated {len(structures):4} candidates in {time_to_string(elapsed, verbose=True)}.')
70
+
71
+ if len(structures) > 0:
72
+ structures_out.append(structures)
73
+ constr_ids.append(constrained_indices)
74
+
75
+ structures_out = np.concatenate(structures_out)
76
+
77
+ embedder.log(f'\n--> Multiembed completed: generated {len(structures_out)} candidates in {time_to_string(time.perf_counter() - embedder.t_start_run, verbose=True)}.')
78
+
79
+ # only get interaction constraints, as the internal will be added later during refinement
80
+ embedder.constrained_indices = np.concatenate(constr_ids)
81
+
82
+ return structures_out
83
+
84
+ def run_child_embedder(
85
+ mol1_name,
86
+ mol2_name,
87
+ constrained_indices,
88
+ i,
89
+ options,
90
+ ):
91
+
92
+ from firecode.embedder import Embedder, RunEmbedding
93
+
94
+ start_dir = os.getcwd()
95
+ foldername = f'firecode_embed{i+1}'
96
+ (ix_1, ix_2), (iy_1, iy_2) = constrained_indices
97
+
98
+ # create a dedicated folder
99
+ if not os.path.isdir(os.path.join(os.getcwd(), foldername)):
100
+ os.mkdir(foldername)
101
+
102
+ # copy structure files into it
103
+ copy(os.path.join(os.getcwd(), mol1_name),
104
+ os.path.join(os.getcwd(), foldername))
105
+ copy(os.path.join(os.getcwd(), mol2_name),
106
+ os.path.join(os.getcwd(), foldername))
107
+
108
+ os.chdir(foldername)
109
+ child_name = f'embed{i+1}_input.txt'
110
+
111
+ with open(child_name, 'w') as f:
112
+ extra = ''
113
+ extra += ' debug' if options.debug else ''
114
+ extra += ' simpleorbitals' if options.simpleorbitals else ''
115
+ extra += f' shrink={options.shrink_multiplier}' if options.shrink else ''
116
+
117
+ f.write(f'noopt rigid{extra}\n')
118
+ f.write(f'{mol1_name} {ix_1}x {iy_1}y\n')
119
+ f.write(f'{mol2_name} {ix_2}x {iy_2}y\n')
120
+
121
+ with suppress_stdout_stderr():
122
+
123
+ child_name = os.path.join(os.getcwd(), child_name)
124
+ child_embedder = Embedder(child_name, f'embed{i+1}')
125
+ child_embedder = RunEmbedding(child_embedder)
126
+
127
+ child_embedder._set_reactive_atoms_cumnums()
128
+ child_embedder.write_mol_info()
129
+ child_embedder.log(f'\n--> FIRECODE multiembed child process - arrangement {i+1}')
130
+ child_embedder.t_start_run = time.perf_counter()
131
+
132
+ try:
133
+ child_embedder.generate_candidates()
134
+ child_embedder.compenetration_refining()
135
+ child_embedder.fitness_refining()
136
+ child_embedder.similarity_refining(rmsd=False, verbose=True)
137
+ child_embedder.write_structures('unoptimized', energies=False)
138
+
139
+ except ZeroCandidatesError:
140
+ child_embedder.structures = []
141
+
142
+ child_embedder.log(f'\n--> Child process terminated ({time_to_string(time.perf_counter() - child_embedder.t_start_run, verbose=True)})')
143
+
144
+ os.chdir(start_dir)
145
+ if not options.debug:
146
+ rmtree(os.path.join(os.getcwd(), foldername))
147
+
148
+ return child_embedder.structures, child_embedder.constrained_indices
firecode/nci.py ADDED
@@ -0,0 +1,186 @@
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
+ from itertools import combinations
24
+
25
+ import numpy as np
26
+
27
+ from firecode.algebra import norm_of
28
+ from firecode.parameters import nci_dict
29
+ from firecode.pt import pt
30
+ from firecode.graph_manipulations import is_phenyl
31
+
32
+
33
+ def get_nci(coords, atomnos, constrained_indices, ids):
34
+ '''
35
+ Returns a list of guesses for intermolecular non-covalent
36
+ interactions between molecular fragments/atoms. Used to get
37
+ a hint of the most prominent NCIs that drive stereo/regio selectivity.
38
+ '''
39
+ nci = []
40
+ print_list = []
41
+
42
+ symbols = [pt[i].symbol for i in atomnos]
43
+ constrained_indices = constrained_indices.ravel()
44
+
45
+ print_list, nci = _get_nci_atomic_pairs(coords, symbols, constrained_indices, ids)
46
+ # Initialize with atomic pairs NCIs
47
+
48
+ # Start checking group contributions
49
+ aromatic_centers = _get_aromatic_centers(coords, symbols, ids)
50
+ # print(f'structure has {len(aromatic_centers)} phenyl rings')
51
+
52
+ # checking phenyl-atom pairs and phenyl-phenyl pairs
53
+ pl, nc = _get_nci_aromatic_rings(coords, symbols, ids, aromatic_centers)
54
+ print_list += pl
55
+ nci += nc
56
+
57
+ return nci, print_list
58
+
59
+ def _get_nci_atomic_pairs(coords, symbols, constrained_indices, ids):
60
+ '''
61
+ '''
62
+ print_list = []
63
+ nci = []
64
+
65
+ cum_ids = np.cumsum(ids)
66
+
67
+ for i1, _ in enumerate(coords):
68
+ # check atomic pairs (O-H, N-H, ...)
69
+
70
+ start_of_next_mol = cum_ids[next(i for i,n in enumerate(cum_ids) if i1 < n)]
71
+ # ensures that we are only taking into account intermolecular NCIs
72
+
73
+ for i2, _ in enumerate(coords[start_of_next_mol:]):
74
+ i2 += start_of_next_mol
75
+
76
+ if (i1 not in constrained_indices) and (i2 not in constrained_indices):
77
+ # ignore atoms involved in constraints
78
+
79
+ s = ''.join(sorted([symbols[i1], symbols[i2]]))
80
+ # print(f'Checking pair {i1}/{i2}')
81
+
82
+ if s in nci_dict:
83
+ threshold, nci_type = nci_dict[s]
84
+ dist = norm_of(coords[i1]-coords[i2])
85
+
86
+ if dist < threshold:
87
+
88
+ print_list.append(nci_type + f' ({round(dist, 2)} A, indices {i1}/{i2})')
89
+ # string to be printed in log
90
+
91
+ nci.append((nci_type, i1, i2))
92
+ # tuple to be used in identifying the NCI
93
+
94
+ return print_list, nci
95
+
96
+ def _get_nci_aromatic_rings(coords, symbols, ids, aromatic_centers):
97
+ '''
98
+ '''
99
+ cum_ids = np.cumsum(ids)
100
+ print_list, nci = [], []
101
+
102
+ for owner, center in aromatic_centers:
103
+ for i, atom in enumerate(coords):
104
+
105
+ if i < cum_ids[0]:
106
+ atom_owner = 0
107
+ else:
108
+ atom_owner = next(i for i,n in enumerate(np.cumsum(ids)) if i < n)
109
+
110
+ if atom_owner != owner:
111
+ # if this atom belongs to a molecule different than the one that owns the phenyl
112
+
113
+ s = ''.join(sorted(['Ph', symbols[i]]))
114
+ if s in nci_dict:
115
+
116
+ threshold, nci_type = nci_dict[s]
117
+ dist = norm_of(center - atom)
118
+
119
+ if dist < threshold:
120
+
121
+ print_list.append(nci_type + f' ({round(dist, 2)} A, atom {i}/ring)')
122
+ # string to be printed in log
123
+
124
+ nci.append((nci_type, i, 'ring'))
125
+ # tuple to be used in identifying the NCI
126
+
127
+ # checking phenyl-phenyl pairs
128
+ for i, owner_center in enumerate(aromatic_centers):
129
+ owner1, center1 = owner_center
130
+ for owner2, center2 in aromatic_centers[i+1:]:
131
+ if owner1 != owner2:
132
+ # if this atom belongs to a molecule different than owner
133
+
134
+ threshold, nci_type = nci_dict['PhPh']
135
+ dist = norm_of(center1 - center2)
136
+
137
+ if dist < threshold:
138
+
139
+ print_list.append(nci_type + f' ({round(dist, 2)} A, ring/ring)')
140
+ # string to be printed in log
141
+
142
+ nci.append((nci_type, 'ring', 'ring'))
143
+ # tuple to be used in identifying the NCI
144
+ return print_list, nci
145
+
146
+ def _get_aromatic_centers(coords, symbols, ids):
147
+ '''
148
+ '''
149
+ cum_ids = np.cumsum(ids)
150
+ masks = []
151
+
152
+ for mol, _ in enumerate(ids):
153
+
154
+ if mol == 0:
155
+ mol_mask = slice(0, cum_ids[0])
156
+ filler = 0
157
+ else:
158
+ mol_mask = slice(cum_ids[mol-1], cum_ids[mol])
159
+ filler = cum_ids[mol-1]
160
+
161
+ aromatics_indices = np.array([i+filler for i, s in enumerate(symbols[mol_mask]) if s in ('C','N')])
162
+
163
+ if len(aromatics_indices) > 5:
164
+ # only check for phenyls in molecules with more than 5 C/N atoms
165
+
166
+ masks.append(list(combinations(aromatics_indices, 6)))
167
+ # all possible combinations of picking 6 C/N atoms from this molecule
168
+
169
+ aromatic_centers = []
170
+
171
+ if masks:
172
+
173
+ masks = np.concatenate(masks)
174
+
175
+ for mask in masks:
176
+
177
+ if is_phenyl(coords[mask]):
178
+
179
+ center = np.mean(coords[mask], axis=0)
180
+
181
+ owner = next(i for i,n in enumerate(np.cumsum(ids)) if np.all(mask < n))
182
+ # index of the molecule that owns that phenyl ring
183
+
184
+ aromatic_centers.append((owner, center))
185
+
186
+ return aromatic_centers