File: contour_exploration.py

package info (click to toggle)
python-ase 3.26.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 15,484 kB
  • sloc: python: 148,112; xml: 2,728; makefile: 110; javascript: 47
file content (464 lines) | stat: -rw-r--r-- 18,729 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
# fmt: off

from typing import IO, Optional, Union

import numpy as np

from ase import Atoms
from ase.optimize.optimize import Dynamics


def subtract_projection(a, b):
    '''returns new vector that removes vector a's projection vector b. Is
    also equivalent to the vector rejection.'''
    aout = a - np.vdot(a, b) / np.vdot(b, b) * b
    return aout


def normalize(a):
    '''Makes a unit vector out of a vector'''
    return a / np.linalg.norm(a)


class ContourExploration(Dynamics):

    def __init__(
        self,
        atoms: Atoms,
        maxstep: float = 0.5,
        parallel_drift: float = 0.1,
        energy_target: Optional[float] = None,
        angle_limit: Optional[float] = 20.0,
        potentiostat_step_scale: Optional[float] = None,
        remove_translation: bool = False,
        use_frenet_serret: bool = True,
        initialization_step_scale: float = 1e-2,
        use_target_shift: bool = True,
        target_shift_previous_steps: int = 10,
        use_tangent_curvature: bool = False,
        rng=np.random,
        force_consistent: Optional[bool] = None,
        trajectory: Optional[str] = None,
        logfile: Optional[Union[IO, str]] = None,
        append_trajectory: bool = False,
        loginterval: int = 1,
    ):
        """Contour Exploration object.

        Parameters:

        atoms: Atoms object
            The Atoms object to operate on. Atomic velocities are required for
            the method. If the atoms object does not contain velocities,
            random ones will be applied.

        maxstep: float
            Used to set the maximum distance an atom can move per
            iteration (default value is 0.5 Å).

        parallel_drift: float
            The fraction of the update step that is parallel to the contour but
            in a random direction. Used to break symmetries.

        energy_target: float
            The total system potential energy for that the potentiostat attepts
            to maintain. (defaults the initial potential energy)

        angle_limit: float or None
            Limits the stepsize to a maximum change of direction angle using the
            curvature. Gives a scale-free means of tuning the stepsize on the
            fly. Typically less than 30 degrees gives reasonable results but
            lower angle limits result in higher potentiostatic accuracy. Units
            of degrees. (default 20°)

        potentiostat_step_scale: float or None
            Scales the size of the potentiostat step. The potentiostat step is
            determined by linear extrapolation from the current potential energy
            to the target_energy with the current forces. A
            potentiostat_step_scale > 1.0 overcorrects and < 1.0
            undercorrects. By default, a simple heuristic is used to selected
            the valued based on the parallel_drift. (default None)

        remove_translation: boolean
            When True, the net momentum is removed at each step. Improves
            potentiostatic accuracy slightly for bulk systems but should not be
            used with constraints. (default False)

        use_frenet_serret: Bool
            Controls whether or not the Taylor expansion of the Frenet-Serret
            formulas for curved path extrapolation are used. Required for using
            angle_limit based step scalling. (default True)

        initialization_step_scale: float
            Controls the scale of the initial step as a multiple of maxstep.
            (default 1e-2)

        use_target_shift: boolean
            Enables shifting of the potentiostat target to compensate for
            systematic undercorrection or overcorrection by the potentiostat.
            Uses the average of the *target_shift_previous_steps* to prevent
            coupled occilations. (default True)

        target_shift_previous_steps: int
            The number of pevious steps to average when using use_target_shift.
            (default 10)

        use_tangent_curvature: boolean
            Use the velocity unit tangent rather than the contour normals from
            forces to compute the curvature. Usually not as accurate.
            (default False)

        rng: a random number generator
            Lets users control the random number generator for the
            parallel_drift vector. (default numpy.random)

         force_consistent: boolean
             (default None)

        trajectory: Trajectory object or str  (optional)
            Attach trajectory object.  If *trajectory* is a string a
            Trajectory will be constructed.  Default: None.

        logfile: file object or str (optional)
            If *logfile* is a string, a file with that name will be opened.
            Use '-' for stdout.  Default: None.

        loginterval: int (optional)
            Only write a log line for every *loginterval* time steps.
            Default: 1

        append_trajectory: boolean
            Defaults to False, which causes the trajectory file to be
            overwriten each time the dynamics is restarted from scratch.
            If True, the new structures are appended to the trajectory
            file instead.
        """

        if potentiostat_step_scale is None:
            # a heuristic guess since most systems will overshoot when there is
            # drift
            self.potentiostat_step_scale = 1.1 + 0.6 * parallel_drift
        else:
            self.potentiostat_step_scale = potentiostat_step_scale

        self.rng = rng
        self.remove_translation = remove_translation
        self.use_frenet_serret = use_frenet_serret
        self.use_tangent_curvature = use_tangent_curvature
        self.initialization_step_scale = initialization_step_scale
        self.maxstep = maxstep
        self.angle_limit = angle_limit
        self.parallel_drift = parallel_drift
        self.use_target_shift = use_target_shift

        # These will be populated once self.step() is called, but could be set
        # after instantiating with ce = ContourExploration(...) like so:
        # ce.Nold = Nold
        # ce.r_old = atoms_old.get_positions()
        # ce.Told  = Told
        # to resume a previous contour trajectory.

        self.T = None
        self.Told = None
        self.N = None
        self.Nold = None
        self.r_old = None
        self.r = None

        if energy_target is None:
            self.energy_target = atoms.get_potential_energy(
                force_consistent=True)
        else:
            self.energy_target = energy_target

        # Initizing the previous steps at the target energy slows
        # target_shifting untill the system has had
        # 'target_shift_previous_steps' steps to equilibrate and should prevent
        # occilations. These need to be initialized before the initialize_old
        # step to prevent a crash
        self.previous_energies = np.full(target_shift_previous_steps,
                                         self.energy_target)

        # these first two are purely for logging,
        # auto scaling will still occur
        # and curvature will still be found if use_frenet_serret == True
        self.step_size = 0.0
        self.curvature = 0

        # loginterval exists for the MolecularDynamics class but not for
        # the more general Dynamics class
        Dynamics.__init__(self, atoms,
                          logfile, trajectory,  # loginterval,
                          append_trajectory=append_trajectory,
                          )

        self._actual_atoms = atoms

        # we need velocities or NaNs will be produced,
        # if none are provided we make random ones
        velocities = self._actual_atoms.get_velocities()
        if np.linalg.norm(velocities) < 1e-6:
            # we have to pass dimension since atoms are not yet stored
            atoms.set_velocities(self.rand_vect())

    # Required stuff for Dynamics
    def todict(self):
        return {'type': 'contour-exploration',
                'dyn-type': self.__class__.__name__,
                'stepsize': self.step_size}

    def run(self, steps=50):
        """ Call Dynamics.run and adjust max_steps """
        return Dynamics.run(self, steps=steps)

    def log(self, gradient):
        if self.logfile is not None:
            # name = self.__class__.__name__
            if self.nsteps == 0:
                args = (
                    "Step",
                    "Energy_Target",
                    "Energy",
                    "Curvature",
                    "Step_Size",
                    "Energy_Deviation_per_atom")
                msg = "# %4s %15s %15s %12s %12s %15s\n" % args
                self.logfile.write(msg)
            e = self._actual_atoms.get_potential_energy(force_consistent=True)
            dev_per_atom = (e - self.energy_target) / len(self._actual_atoms)
            args = (
                self.nsteps,
                self.energy_target,
                e,
                self.curvature,
                self.step_size,
                dev_per_atom)
            msg = "%6d %15.6f %15.6f %12.6f %12.6f %24.9f\n" % args
            self.logfile.write(msg)

            self.logfile.flush()

    def rand_vect(self):
        '''Returns a random (Natoms,3) vector'''
        vect = self.rng.normal(size=(len(self._actual_atoms), 3))
        return vect

    def create_drift_unit_vector(self, N, T):
        '''Creates a random drift unit vector with no projection on N or T and
        with out a net translation so systems don't wander'''
        drift = self.rand_vect()
        drift = subtract_projection(drift, N)
        drift = subtract_projection(drift, T)
        # removes net translation, so systems don't wander
        drift = drift - drift.sum(axis=0) / len(self._actual_atoms)
        D = normalize(drift)
        return D

    def compute_step_contributions(self, potentiostat_step_size):
        '''Computes the orthogonal component sizes of the step so that the net
        step obeys the smaller of step_size or maxstep.'''
        if abs(potentiostat_step_size) < self.step_size:
            delta_s_perpendicular = potentiostat_step_size
            contour_step_size = np.sqrt(
                self.step_size**2 - potentiostat_step_size**2)
            delta_s_parallel = np.sqrt(
                1 - self.parallel_drift**2) * contour_step_size
            delta_s_drift = contour_step_size * self.parallel_drift

        else:
            # in this case all priority goes to potentiostat terms
            delta_s_parallel = 0.0
            delta_s_drift = 0.0
            delta_s_perpendicular = np.sign(
                potentiostat_step_size) * self.step_size

        return delta_s_perpendicular, delta_s_parallel, delta_s_drift

    def _compute_update_without_fs(self, potentiostat_step_size, scale=1.0):
        '''Only uses the forces to compute an orthogonal update vector'''

        # Without the use of curvature there is no way to estimate the
        # limiting step size
        self.step_size = self.maxstep * scale

        delta_s_perpendicular, delta_s_parallel, delta_s_drift = \
            self.compute_step_contributions(
                potentiostat_step_size)

        dr_perpendicular = self.N * delta_s_perpendicular
        dr_parallel = delta_s_parallel * self.T

        D = self.create_drift_unit_vector(self.N, self.T)
        dr_drift = D * delta_s_drift

        dr = dr_parallel + dr_drift + dr_perpendicular
        dr = self.step_size * normalize(dr)
        return dr

    def _compute_update_with_fs(self, potentiostat_step_size):
        '''Uses the Frenet–Serret formulas to perform curvature based
        extrapolation to compute the update vector'''
        # this should keep the dr clear of the constraints
        # by using the actual change, not a velocity vector
        delta_r = self.r - self.rold
        delta_s = np.linalg.norm(delta_r)
        # approximation of delta_s we use this incase an adaptive step_size
        # algo get used

        delta_T = self.T - self.Told
        delta_N = self.N - self.Nold
        dTds = delta_T / delta_s
        dNds = delta_N / delta_s
        if self.use_tangent_curvature:
            curvature = np.linalg.norm(dTds)
            # on a perfect trajectory, the normal can be computed this way,
            # But the normal should always be tied to forces
            # N = dTds / curvature
        else:
            # normals are better since they are fixed to the reality of
            # forces. I see smaller forces and energy errors in bulk systems
            # using the normals for curvature
            curvature = np.linalg.norm(dNds)
        self.curvature = curvature

        if self.angle_limit is not None:
            phi = np.pi / 180 * self.angle_limit
            self.step_size = np.sqrt(2 - 2 * np.cos(phi)) / curvature
            self.step_size = min(self.step_size, self.maxstep)

        # now we can compute a safe step
        delta_s_perpendicular, delta_s_parallel, delta_s_drift = \
            self.compute_step_contributions(
                potentiostat_step_size)

        N_guess = self.N + dNds * delta_s_parallel
        T_guess = self.T + dTds * delta_s_parallel
        # the extrapolation is good at keeping N_guess and T_guess
        # orthogonal but not normalized:
        N_guess = normalize(N_guess)
        T_guess = normalize(T_guess)

        dr_perpendicular = delta_s_perpendicular * (N_guess)

        dr_parallel = delta_s_parallel * self.T * \
            (1 - (delta_s_parallel * curvature)**2 / 6.0) \
            + self.N * (curvature / 2.0) * delta_s_parallel**2

        D = self.create_drift_unit_vector(N_guess, T_guess)
        dr_drift = D * delta_s_drift

        # combine the components
        dr = dr_perpendicular + dr_parallel + dr_drift
        dr = self.step_size * normalize(dr)
        # because we guess our orthonormalization directions,
        # we renormalize to ensure a correct step size
        return dr

    def update_previous_energies(self, energy):
        '''Updates the energy history in self.previous_energies to include the
         current energy.'''
        # np.roll shifts the values to keep nice sequential ordering.
        self.previous_energies = np.roll(self.previous_energies, 1)
        self.previous_energies[0] = energy

    def compute_potentiostat_step_size(self, forces, energy):
        '''Computes the potentiostat step size by linear extrapolation of the
        potential energy using the forces. The step size can be positive or
        negative depending on whether or not the energy is too high or too low.
        '''
        if self.use_target_shift:
            target_shift = self.energy_target - np.mean(self.previous_energies)
        else:
            target_shift = 0.0

        # deltaU is the potential error that will be corrected for
        deltaU = energy - (self.energy_target + target_shift)

        f_norm = np.linalg.norm(forces)
        # can be positive or negative
        potentiostat_step_size = (deltaU / f_norm) * \
            self.potentiostat_step_scale
        return potentiostat_step_size

    def step(self, f=None):
        atoms = self._actual_atoms

        if f is None:
            f = atoms.get_forces()

        # get the velocity vector and old kinetic energy for momentum rescaling
        velocities = atoms.get_velocities()
        KEold = atoms.get_kinetic_energy()

        energy = atoms.get_potential_energy(force_consistent=True)
        self.update_previous_energies(energy)
        potentiostat_step_size = self.compute_potentiostat_step_size(f, energy)

        self.N = normalize(f)
        self.r = atoms.get_positions()
        # remove velocity  projection on forces
        v_parallel = subtract_projection(velocities, self.N)
        self.T = normalize(v_parallel)

        if self.use_frenet_serret:
            if self.Nold is not None and self.Told is not None:
                dr = self._compute_update_with_fs(potentiostat_step_size)
            else:
                # we must have the old positions and vectors for an FS step
                # if we don't, we can only do a small step
                dr = self._compute_update_without_fs(
                    potentiostat_step_size,
                    scale=self.initialization_step_scale)
        else:  # of course we can run less accuratly without FS.
            dr = self._compute_update_without_fs(potentiostat_step_size)

        # now that dr is done, we check if there is translation
        if self.remove_translation:
            net_motion = dr.sum(axis=0) / len(atoms)
            # print(net_motion)
            dr = dr - net_motion
            dr_unit = dr / np.linalg.norm(dr)
            dr = dr_unit * self.step_size

        # save old positions before update
        self.Nold = self.N
        self.rold = self.r
        self.Told = self.T

        # if we have constraints then this will do the first part of the
        # RATTLE algorithm:
        # If we can avoid using momenta, this will be simpler.
        masses = atoms.get_masses()[:, np.newaxis]
        atoms.set_positions(self.r + dr)
        new_momenta = (atoms.get_positions() - self.r) * masses  # / self.dt

        # We need to store the momenta on the atoms before calculating
        # the forces, as in a parallel Asap calculation atoms may
        # migrate during force calculations, and the momenta need to
        # migrate along with the atoms.
        atoms.set_momenta(new_momenta, apply_constraint=False)

        # Now we get the new forces!
        f = atoms.get_forces(md=True)

        # I don't really know if removing md=True from above will break
        # compatibility with RATTLE, leaving it alone for now.
        f_constrained = atoms.get_forces()
        # but this projection needs the forces to be consistent with the
        # constraints. We have to set the new velocities perpendicular so they
        # get logged properly in the trajectory files.
        vnew = subtract_projection(atoms.get_velocities(), f_constrained)
        # using the md = True forces like this:
        # vnew = subtract_projection(atoms.get_velocities(), f)
        # will not work with constraints
        atoms.set_velocities(vnew)

        # rescaling momentum to maintain constant kinetic energy.
        KEnew = atoms.get_kinetic_energy()
        Ms = np.sqrt(KEold / KEnew)  # Ms = Momentum_scale
        atoms.set_momenta(Ms * atoms.get_momenta())

        # Normally this would be the second part of RATTLE
        # will be done here like this:
        #  atoms.set_momenta(atoms.get_momenta() + 0.5 * self.dt * f)
        return f