micki 2.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.
micki/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from micki.reactants import Liquid, Gas, Adsorbate, Electron
2
+ from micki.model import Reaction, Model
3
+ from micki.eref import EnergyReference
4
+ from micki.analysis import ModelAnalysis
5
+ from micki.lattice import Lattice
6
+
7
+ __all__ = ['Liquid', 'Gas', 'Adsorbate', 'Electron', 'Reaction', 'Model',
8
+ 'EnergyReference', 'ModelAnalysis', 'Lattice']
micki/analysis.py ADDED
@@ -0,0 +1,287 @@
1
+ """Module for doing sensitivity analysis of microkinetic model"""
2
+
3
+ import collections.abc
4
+
5
+ import numpy as np
6
+ import sympy as sym
7
+
8
+ from ase.units import kB
9
+
10
+ from micki.reactants import Adsorbate, _Fluid
11
+
12
+
13
+ class ModelAnalysis:
14
+ def __init__(self, model, product_reaction, Uequil, tol=1e-3, dt=3600):
15
+ self.model = model
16
+ self.reaction_name = product_reaction
17
+ self.product_reaction = model.reactions[product_reaction]
18
+ self.Uequil = Uequil
19
+ self.tol = tol
20
+ self.dt = dt
21
+
22
+ self.model.set_initial_conditions(self.Uequil)
23
+
24
+ t, self.U, self.r = self.model.find_steady_state()
25
+ model.finalize()
26
+
27
+ self.species_symbols = []
28
+ for species in self.model._species:
29
+ if species.symbol is not None:
30
+ self.species_symbols.append(species)
31
+ self.rmid = self.r[self.reaction_name]
32
+
33
+ def campbell_rate_control(self, rxn_name, scale=0.001):
34
+ reaction = self.model.reactions[rxn_name]
35
+
36
+ subs = {}
37
+ for species in self.species_symbols:
38
+ subs[species.symbol] = self.U[species.label]
39
+
40
+ kmid = reaction.get_kfor(self.model.T,
41
+ self.model.Asite,
42
+ self.model.z)
43
+
44
+ if isinstance(kmid, sym.Basic):
45
+ kmid = kmid.subs(subs)
46
+
47
+ reaction.set_scale('kfor', 1.0 - scale)
48
+ reaction.set_scale('krev', 1.0 - scale)
49
+ reaction.update(self.model.T,
50
+ self.model.Asite,
51
+ self.model.z,
52
+ force=True)
53
+ klow = reaction.get_kfor()
54
+ model = self.model.copy()
55
+
56
+ try:
57
+ t1, U1, r1 = model.find_steady_state()
58
+ finally:
59
+ reaction.set_scale('kfor', 1.0)
60
+ reaction.set_scale('krev', 1.0)
61
+
62
+ model.finalize()
63
+ rlow = r1[self.reaction_name]
64
+ if isinstance(klow, sym.Basic):
65
+ subs = {}
66
+ for species in self.species_symbols:
67
+ subs[species.symbol] = U1[species.label]
68
+ klow = klow.subs(subs)
69
+
70
+ reaction.set_scale('kfor', 1.0 + scale)
71
+ reaction.set_scale('krev', 1.0 + scale)
72
+ reaction.update(self.model.T,
73
+ self.model.Asite,
74
+ self.model.z,
75
+ force=True)
76
+ khigh = reaction.get_kfor()
77
+ model = self.model.copy()
78
+
79
+ try:
80
+ t2, U2, r2 = model.find_steady_state()
81
+ finally:
82
+ reaction.set_scale('kfor', 1.0)
83
+ reaction.set_scale('krev', 1.0)
84
+
85
+ model.finalize()
86
+ rhigh = r2[self.reaction_name]
87
+ if isinstance(khigh, sym.Basic):
88
+ subs = {}
89
+ for species in self.species_symbols:
90
+ subs[species.symbol] = U2[species.label]
91
+ khigh = khigh.subs(subs)
92
+ reaction.set_scale('kfor', 1.0)
93
+ reaction.set_scale('krev', 1.0)
94
+
95
+ return kmid * (rhigh - rlow) / (self.rmid * (khigh - klow))
96
+
97
+ def thermodynamic_rate_control(self, names, dg=None):
98
+ """Degree of thermodynamic rate control, -(kT/r) dr/dG.
99
+
100
+ The free energies of all species in `names` are shifted together
101
+ by -dg and +dg (default 0.001 kT) and the derivative is taken by
102
+ central differences.
103
+ """
104
+ T = self.model.T
105
+ if dg is None:
106
+ dg = 0.001 * kB * T
107
+
108
+ if not isinstance(names, (list, tuple)):
109
+ species = [self.model.species[names]]
110
+ else:
111
+ species = [self.model.species[name] for name in names]
112
+
113
+ for sp in species:
114
+ sp.dE -= dg
115
+
116
+ for reaction in self.model._reactions:
117
+ reaction.update(T=self.model.T,
118
+ Asite=self.model.Asite,
119
+ L=self.model.z,
120
+ force=True)
121
+
122
+ model = self.model.copy(initialize=False)
123
+ model.set_initial_conditions(self.U)
124
+
125
+ try:
126
+ t1, U1, r1 = model.find_steady_state()
127
+ finally:
128
+ for sp in species:
129
+ sp.dE += dg
130
+
131
+ model.finalize()
132
+ rlow = r1[self.reaction_name]
133
+
134
+ for sp in species:
135
+ sp.dE += dg
136
+
137
+ for reaction in self.model._reactions:
138
+ reaction.update(T=self.model.T,
139
+ Asite=self.model.Asite,
140
+ L=self.model.z,
141
+ force=True)
142
+
143
+ model = self.model.copy(initialize=False)
144
+ model.set_initial_conditions(self.U)
145
+
146
+ try:
147
+ t2, U2, r2 = model.find_steady_state()
148
+ finally:
149
+ for sp in species:
150
+ sp.dE -= dg
151
+
152
+ for reaction in self.model._reactions:
153
+ reaction.update(T=self.model.T,
154
+ Asite=self.model.Asite,
155
+ L=self.model.z,
156
+ force=True)
157
+
158
+ model.finalize()
159
+ rhigh = r2[self.reaction_name]
160
+
161
+ # central difference: the two rates are 2 * dg apart in free energy
162
+ return (rlow - rhigh) * kB * T / (self.rmid * 2 * dg)
163
+
164
+ def activation_barrier(self, dT=0.01):
165
+ T = self.model.T
166
+
167
+ model = self.model.copy(initialize=False)
168
+ model.T = T - dT
169
+ model.set_initial_conditions(self.Uequil)
170
+ t1, U1, r1 = model.find_steady_state()
171
+ model.finalize()
172
+
173
+ rlow = r1[self.reaction_name]
174
+
175
+ model = self.model.copy(initialize=False)
176
+ model.T = T + dT
177
+ model.set_initial_conditions(self.Uequil)
178
+ t2, U2, r2 = model.find_steady_state()
179
+ model.finalize()
180
+
181
+ rhigh = r2[self.reaction_name]
182
+
183
+ return kB * T**2 * (rhigh - rlow) / (self.rmid * 2 * dT)
184
+
185
+ def rate_order(self, name, drho=0.05):
186
+ species = self.model.species[name]
187
+
188
+ rhomid = self.Uequil[species.label]
189
+ if not rhomid > 0:
190
+ raise ValueError('{} has no positive concentration'.format(name))
191
+
192
+ U0 = self.Uequil.copy()
193
+ rholow = rhomid * (1.0 - drho)
194
+ U0[species.label] = rholow
195
+ model = self.model.copy(initialize=False)
196
+ model.set_initial_conditions(U0)
197
+ t1, U1, r1 = model.find_steady_state()
198
+ model.finalize()
199
+ rlow = r1[self.reaction_name]
200
+
201
+ rhohigh = rhomid * (1.0 + drho)
202
+ U0[species.label] = rhohigh
203
+ model.set_initial_conditions(U0)
204
+ t2, U2, r2 = model.find_steady_state()
205
+ model.finalize()
206
+ rhigh = r2[self.reaction_name]
207
+
208
+ return (rhomid / self.rmid) * (rhigh - rlow) / (rhohigh - rholow)
209
+
210
+ def drate_order_dg(self, fluid, adsorbates, rho_scale=0.01, g_scale=0.01):
211
+ """(rho/r) d^2 r / (d rho dG): the change of the rate order in
212
+ `fluid` with the free energy of `adsorbates` (shifted together).
213
+
214
+ Central differences in the concentration of `fluid` (+-drho) and in
215
+ the free energies of `adsorbates` (+-dg).
216
+ """
217
+ if not isinstance(fluid, _Fluid):
218
+ raise TypeError('fluid must be a Gas or Liquid species')
219
+
220
+ if not isinstance(adsorbates, collections.abc.Iterable):
221
+ adsorbates = [adsorbates]
222
+
223
+ if not isinstance(adsorbates[0], Adsorbate):
224
+ raise TypeError('adsorbates must be Adsorbate species')
225
+
226
+ rhomid = self.U[fluid.label]
227
+ if not rhomid > 0:
228
+ raise ValueError('{} has no positive concentration'
229
+ ''.format(fluid.label))
230
+ rmid = self.r[self.reaction_name]
231
+ gmid = adsorbates[0].get_G(self.model.T)
232
+ if isinstance(gmid, sym.Basic):
233
+ trans = {}
234
+ for species in self.model._species:
235
+ if isinstance(species, Adsorbate) \
236
+ and species.symbol is not None:
237
+ trans[species.symbol] = self.U[species.label]
238
+ gmid = gmid.subs(trans)
239
+ dg = np.abs(gmid * g_scale * 2)
240
+ drho = rhomid * rho_scale * 2
241
+
242
+ dr = 0
243
+
244
+ def set_dg(species, dg):
245
+ species.dE += dg
246
+
247
+ for i in [-1, 1]:
248
+ for adsorbate in adsorbates:
249
+ set_dg(adsorbate, i * dg)
250
+
251
+ for reaction in self.model._reactions:
252
+ reaction.update(T=self.model.T,
253
+ Asite=self.model.Asite,
254
+ L=self.model.z,
255
+ force=True)
256
+
257
+ for j in [-1, 1]:
258
+ U0 = self.Uequil.copy()
259
+ U0[fluid.label] = rhomid + j * drho
260
+
261
+ model = self.model.copy(initialize=False)
262
+ model.set_initial_conditions(U0)
263
+
264
+ ti, Ui, ri = model.find_steady_state()
265
+ dr += i * j * ri[self.reaction_name]
266
+
267
+ for adsorbate in adsorbates:
268
+ set_dg(adsorbate, -i * dg)
269
+
270
+ for reaction in self.model._reactions:
271
+ reaction.update(T=self.model.T,
272
+ Asite=self.model.Asite,
273
+ L=self.model.z,
274
+ force=True)
275
+
276
+ # the four rates are 2 * dg and 2 * drho apart
277
+ return (rhomid / rmid) * dr / (4 * dg * drho)
278
+
279
+ def check_converged(self, *vals):
280
+ for val in vals:
281
+ for i, key in enumerate(val[0]):
282
+ if np.abs(val[-1][key] - val[-2][key]) > self.tol:
283
+ raise ValueError("Calculation not converged ({}: {}, "
284
+ "change {})! Increase dt or use better "
285
+ "initial guess.".format(
286
+ key, val[-1][key],
287
+ val[-1][key] - val[-2][key]))
micki/db.py ADDED
@@ -0,0 +1,80 @@
1
+ import warnings
2
+
3
+ from ase.db import connect
4
+ from ase.db.core import Database
5
+
6
+ from micki.reactants import Adsorbate, Gas, Liquid
7
+ from micki.eref import EnergyReference
8
+
9
+ class MickiDBReadError(ValueError):
10
+ pass
11
+
12
+ # Attempts to parse attribute 'name' from dictionary 'data' and raises a
13
+ # parse error if it cannot be found.
14
+ def get_data(row, param):
15
+ if param not in row.data:
16
+ raise MickiDBReadError("DB row named {} does not have '{}' entry!"
17
+ "".format(row.name, param))
18
+ return row.data[param]
19
+
20
+ # Converts a single ASE DB row to a Micki Thermo object.
21
+ def row_to_thermo(row):
22
+ name = row.name
23
+ freqs = get_data(row, 'freqs')
24
+ thermo = get_data(row, 'thermo')
25
+ sites = get_data(row, 'sites')
26
+ rhoref = get_data(row, 'rhoref')
27
+ dE = get_data(row, 'dE')
28
+ symm = get_data(row, 'symm')
29
+ ts = get_data(row, 'ts')
30
+ spin = get_data(row, 'spin')
31
+ D = get_data(row, 'D')
32
+ S = get_data(row, 'S')
33
+
34
+ if thermo == 'Adsorbate':
35
+ return Adsorbate(row.toatoms(), name, freqs,
36
+ ts=ts, sites=sites, dE=dE, symm=symm)
37
+ elif thermo == 'Gas':
38
+ return Gas(row.toatoms(), name, freqs,
39
+ symm=symm, spin=spin, rhoref=rhoref, dE=dE)
40
+ elif thermo == 'Liquid':
41
+ return Liquid(row.toatoms(), name, freqs,
42
+ symm=symm, spin=spin, D=D, S=S, rhoref=rhoref, dE=dE)
43
+ else:
44
+ raise ValueError('Unknown Thermo type {}!'.format(thermo))
45
+
46
+ # Creates a dictionary of Thermo objects from a properly-formatted ASE DB file.
47
+ def read_from_db(db, names=None, eref=None):
48
+ if isinstance(db, str):
49
+ db = connect(db)
50
+ elif not isinstance(db, Database):
51
+ raise ValueError("Must pass active ASE DB connection, "
52
+ "or name of ASE DB file!")
53
+
54
+ species = {}
55
+
56
+ for row in db.select():
57
+ name = row.name
58
+ try:
59
+ species[name] = row_to_thermo(row)
60
+ except MickiDBReadError:
61
+ warnings.warn("Could not parse row {}, skipping.".format(name),
62
+ RuntimeWarning, stacklevel=2)
63
+
64
+ for name, sp in species.items():
65
+ newsites = []
66
+ for site in sp.sites:
67
+ if site in species:
68
+ newsites.append(species[site])
69
+ else:
70
+ raise ValueError("Unknown site named {}!".format(site))
71
+ sp.sites = newsites
72
+
73
+ if eref is not None:
74
+ reference = EnergyReference([species[name] for name in eref])
75
+ for name, sp in species.items():
76
+ sp.eref = reference
77
+
78
+ if names is not None:
79
+ return {name: species[name] for name in names}
80
+ return species
micki/eref.py ADDED
@@ -0,0 +1,85 @@
1
+ """Reference atoms object"""
2
+
3
+ import numpy as np
4
+
5
+ from ase import Atoms
6
+ from ase.io import read
7
+ from ase.data import chemical_symbols
8
+ from ase.db.row import AtomsRow
9
+
10
+ from micki.reactants import _Thermo
11
+
12
+
13
+ class EnergyReference(dict):
14
+ """Construct an atomic energy reference.
15
+
16
+ This routine accepts an iterable containing N paths to ASE-readable
17
+ geometry files with N unique elements between them, and returns a dict-like
18
+ containing those unique elements as keys and their reference energy as
19
+ values.
20
+
21
+ By default, use the same geometry files as those which contain the
22
+ vibrational frequencies for the microkinetic model.
23
+ """
24
+ def __init__(self, species, index=0):
25
+ dict.__init__(self)
26
+ symbols = []
27
+ energies = []
28
+ elements = set()
29
+
30
+ self.initialized = False
31
+
32
+ for sp in species:
33
+ if isinstance(sp, Atoms):
34
+ conf = sp
35
+ elif isinstance(sp, AtomsRow):
36
+ conf = sp.toatoms()
37
+ elif isinstance(sp, _Thermo):
38
+ conf = sp.atoms
39
+ else:
40
+ conf = read(sp, index=index)
41
+ symbols.append(conf.get_chemical_symbols())
42
+ elements = elements.union(symbols[-1])
43
+ energies.append(conf.get_potential_energy())
44
+
45
+ size = len(elements)
46
+ if len(energies) < size:
47
+ raise ValueError("System is underdetermined!")
48
+ elif len(energies) > size:
49
+ raise ValueError("System is overdetermined!")
50
+
51
+ coeff = np.zeros((size, size), dtype=float)
52
+
53
+ for i in range(size):
54
+ for j, symbol in enumerate(elements):
55
+ coeff[i, j] = symbols[i].count(symbol)
56
+ eref = np.linalg.solve(coeff, energies)
57
+
58
+ for i, symbol in enumerate(elements):
59
+ self[symbol] = eref[i]
60
+
61
+ self.initialized = True
62
+
63
+ def __setitem__(self, key, value):
64
+ if self.initialized:
65
+ raise NotImplementedError
66
+ else:
67
+ super().__setitem__(key, value)
68
+
69
+ def __delitem__(self, key):
70
+ raise NotImplementedError
71
+
72
+ def __getitem__(self, key):
73
+ if isinstance(key, str):
74
+ key = key.capitalize()
75
+ elif isinstance(key, int):
76
+ key = chemical_symbols[key]
77
+ return super().__getitem__(key)
78
+
79
+ def copy(self):
80
+ new = EnergyReference.__new__(EnergyReference)
81
+ new.initialized = False
82
+ for key in self:
83
+ new[key] = self[key]
84
+ new.initialized = True
85
+ return new
micki/io.py ADDED
@@ -0,0 +1,138 @@
1
+ import numpy as np
2
+
3
+ from ase.io import read
4
+ from ase.units import _hplanck, J, m, kg
5
+
6
+ from micki.masses import masses
7
+
8
+ def parse_vasp_out(filename, ignore_atoms=()):
9
+ atoms = read(filename, index=0)
10
+ for atom in atoms:
11
+ atom.mass = masses[atom.symbol]
12
+ if 'OUTCAR' in filename:
13
+ # This reads the hessian from the OUTCAR and diagonalizes it
14
+ # to find the frequencies, rather than reading the frequencies
15
+ # directly from the OUTCAR. This is to ensure we use the same
16
+ # unit conversion factors, and also to make sure we use the same
17
+ # atom masses for all calculations. Also, allows for the possibility
18
+ # of doing partial hessian diagonalization should we want to do that.
19
+ hessblock = 0
20
+ with open(filename, 'r') as f:
21
+ for line in f:
22
+ line = line.strip()
23
+ if line != '':
24
+ if hessblock == 1:
25
+ if line.startswith('---'):
26
+ hessblock = 2
27
+
28
+ elif hessblock == 2:
29
+ line = line.split()
30
+ dof = len(line)
31
+ hess = np.zeros((dof, dof), dtype=float)
32
+ index = np.zeros(dof, dtype=int)
33
+ cart = np.zeros(dof, dtype=int)
34
+ for i, direction in enumerate(line):
35
+ index[i] = int(direction[:-1]) - 1
36
+ if direction[-1] == 'X':
37
+ cart[i] = 0
38
+ elif direction[-1] == 'Y':
39
+ cart[i] = 1
40
+ elif direction[-1] == 'Z':
41
+ cart[i] = 2
42
+ else:
43
+ raise ValueError("Error reading Hessian!")
44
+ hessblock = 3
45
+ j = 0
46
+
47
+ elif hessblock == 3:
48
+ line = line.split()
49
+ hess[j] = np.array([float(val) for val in line[1:]],
50
+ dtype=float)
51
+ j += 1
52
+
53
+ elif line.startswith('SECOND DERIVATIVES'):
54
+ hessblock = 1
55
+
56
+ elif hessblock == 3:
57
+ break
58
+
59
+ hess = -(hess + hess.T) / 2.
60
+ elif 'vasprun.xml' in filename:
61
+ import xml.etree.ElementTree as ET
62
+
63
+ tree = ET.parse(filename)
64
+ root = tree.getroot()
65
+
66
+ vasp_mass = {}
67
+
68
+ for element in root.find("atominfo/array[@name='atomtypes']/set"):
69
+ vasp_mass[element[1].text.strip()] = float(element[2].text)
70
+
71
+ selective = np.ones((len(atoms), 3), dtype=bool)
72
+ constblock = root.find(
73
+ 'structure[@name="initialpos"]/varray[@name="selective"]')
74
+ if constblock is not None:
75
+ for i, v in enumerate(constblock):
76
+ for j, fixed in enumerate(v.text.split()):
77
+ selective[i, j] = (fixed == 'T')
78
+ index = []
79
+ for i, atom in enumerate(atoms):
80
+ for direction in selective[i]:
81
+ if direction:
82
+ index.append(i)
83
+
84
+ hess = np.zeros((len(index), len(index)), dtype=float)
85
+
86
+ for i, v in enumerate(root.find(
87
+ 'calculation/dynmat/varray[@name="hessian"]')):
88
+ hess[i] = -np.array([float(val) for val in v.text.split()])
89
+
90
+ vasp_massvec = np.zeros(len(index), dtype=float)
91
+ for i, j in enumerate(index):
92
+ vasp_massvec[i] = vasp_mass[atoms[j].symbol]
93
+
94
+ # VASP uses weird masses, so we un-mass-weight here
95
+ hess *= np.sqrt(np.outer(vasp_massvec, vasp_massvec))
96
+
97
+ else:
98
+ raise ValueError('Unknown file format {}!'.format(filename))
99
+ mass = np.array([atoms[i].mass for i in index], dtype=float)
100
+ hess /= np.sqrt(np.outer(mass, mass))
101
+
102
+ # Temporary work around: My test system OUTCARs include some
103
+ # metal atoms in the hessian, this seems to cause some problems
104
+ # with the MKM. So, here I'm taking only the non-metal part
105
+ # of the hessian and diagonalizing that.
106
+ partial = []
107
+ for i, j in enumerate(index):
108
+ if (j in ignore_atoms
109
+ or atoms[j] in ignore_atoms
110
+ or atoms[j].symbol in ignore_atoms):
111
+ continue
112
+ partial.append(i)
113
+ if not partial:
114
+ return atoms, np.array([])
115
+ partial_hess = np.zeros((len(partial), len(partial)))
116
+ partial_index = np.zeros(len(partial), dtype=int)
117
+ for i, a in enumerate(partial):
118
+ partial_index[i] = index[a]
119
+ for j, b in enumerate(partial):
120
+ partial_hess[i, j] = hess[a, b]
121
+
122
+ partial_hess *= _hplanck**2 * J * m**2 * kg / (4 * np.pi**2)
123
+ v, w = np.linalg.eig(partial_hess)
124
+
125
+ # We're taking the square root of an array that could include
126
+ # negative numbers, so the result has to be complex.
127
+ freq = np.sqrt(np.array(v, dtype=complex))
128
+ freqs = np.zeros_like(freq, dtype=float)
129
+
130
+ # We don't want to deal with complex numbers, so we just convert
131
+ # imaginary numbers to negative reals.
132
+ for i, val in enumerate(freq):
133
+ if val.imag == 0:
134
+ freqs[i] = val.real
135
+ else:
136
+ freqs[i] = -val.imag
137
+ freqs.sort()
138
+ return atoms, freqs
micki/lattice.py ADDED
@@ -0,0 +1,92 @@
1
+ """Lattice stuff"""
2
+
3
+ from .reactants import _Thermo
4
+ import numpy as np
5
+ from ase.units import kB
6
+
7
+
8
+ class Lattice:
9
+ def __init__(self, neighborlist):
10
+ self.neighborlist = neighborlist
11
+ self.sites = [site for site in neighborlist]
12
+ if isinstance(self.sites[0], str):
13
+ self.string_names = True
14
+ elif isinstance(self.sites[0], _Thermo):
15
+ self.string_names = False
16
+ else:
17
+ raise ValueError('All sites must be _Thermo objects or strings!')
18
+
19
+ sitetype = str if self.string_names else _Thermo
20
+
21
+ for site in self.sites:
22
+ if not isinstance(site, sitetype):
23
+ raise ValueError('All sites must be _Thermo objects or strings!')
24
+
25
+ # Sanity check the input
26
+ self.totneighbors = {}
27
+ for site, neighbors in neighborlist.items():
28
+ self.totneighbors[site] = 0
29
+ for neighbor, val in neighbors.items():
30
+ self.totneighbors[site] += val
31
+ if neighbor not in self.sites:
32
+ raise ValueError("Neighbor {} is unknown!".format(neighbor))
33
+ for site in self.sites:
34
+ if site not in neighbors:
35
+ neighbors[site] = 0
36
+
37
+ # Create a neighbor list matrix
38
+ nsites = len(self.sites)
39
+ if nsites == 1:
40
+ self.ratio = {self.sites[0]: 1}
41
+ return
42
+ nmat = np.zeros((nsites, nsites), dtype=float)
43
+ for i, a in enumerate(self.sites):
44
+ for j, b in enumerate(self.sites):
45
+ nmat[i, j] = self.neighborlist[b].get(a, 0) / self.totneighbors[a]
46
+
47
+ # Diagonalize to find the element ratio. Only one eigenvector should
48
+ # have all positive values. This is the eigenvector that describes
49
+ # the element ratio
50
+ eigenvals, eigenvecs = np.linalg.eig(nmat)
51
+ for i in range(nsites):
52
+ if np.all(eigenvecs[:, i] < 0) or np.all(eigenvecs[:, i] > 0):
53
+ ratio = np.abs(eigenvecs[:, i])
54
+ ratio /= ratio.sum()
55
+ self.ratio = {site: ratio[i] for i, site in enumerate(self.sites)}
56
+ break
57
+ else:
58
+ raise ValueError("Failed to find the element ratio! Please "
59
+ "double-check your neighbor count. "
60
+ "Eigenvectors: {}".format(eigenvecs))
61
+
62
+ def update_site_names(self, string_to_thermo):
63
+ if not self.string_names:
64
+ raise RuntimeError('Sites are already _Thermo objects!')
65
+
66
+ for site in self.sites:
67
+ if site not in string_to_thermo:
68
+ raise ValueError('No _Thermo object for site {}!'.format(site))
69
+
70
+ for string in string_to_thermo:
71
+ if string not in self.sites:
72
+ raise ValueError('Unknown site name {}!'.format(string))
73
+
74
+ new_neighborlist = {}
75
+ for site, neighbors in self.neighborlist.items():
76
+ new_neighborlist[string_to_thermo[site]] = {
77
+ string_to_thermo[neighbor]: count
78
+ for neighbor, count in neighbors.items()}
79
+
80
+ # rebuild everything (totneighbors, ratio, ...) with the new keys
81
+ self.__init__(new_neighborlist)
82
+
83
+ def get_S_conf(self, sites):
84
+ if sites is None or isinstance(sites, _Thermo) or len(sites) == 1:
85
+ return 0
86
+ nconfs = 1
87
+ for i in range(1, len(sites)):
88
+ ncount = self.neighborlist[sites[i - 1]][sites[i]]
89
+ if ncount == 0:
90
+ raise ValueError("This binding geometry is impossible!")
91
+ nconfs *= ncount / self.ratio[sites[i]]
92
+ return kB * np.log(nconfs)