Coverage for gpaw/solvation/sjm.py: 68%

644 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2025-07-14 00:18 +0000

1""" 

2The solvated jellium method is contained in this module. 

3This enables electronically grand-canonical calculations to be calculated, 

4typically for simulating electrochemical interfaces. 

5 

6This version the the solvated jellium method has been modified 

7and includes the Fluctuation Dissipation Theorem. 

8""" 

9 

10import copy 

11import os 

12import textwrap 

13 

14import ase.io 

15import gpaw.mpi 

16import numpy as np 

17from ase.calculators.calculator import (InputError, Parameters, 

18 PropertyNotPresent, equal) 

19from ase.parallel import paropen 

20from ase.units import Bohr, Ha, _e, kB 

21from gpaw import GPAW_NEW, ConvergenceError 

22from gpaw.dipole_correction import DipoleCorrection 

23from gpaw.fd_operators import Gradient 

24from gpaw.hamiltonian import RealSpaceHamiltonian 

25from gpaw.io.logger import indent 

26from gpaw.jellium import Jellium, JelliumSlab 

27from gpaw.solvation.calculator import OldSolvationGPAW 

28from gpaw.solvation.cavity import Power12Potential, get_pbc_positions 

29from gpaw.solvation.hamiltonian import SolvationRealSpaceHamiltonian 

30from gpaw.solvation.poisson import WeightedFDPoissonSolver 

31from scipy.ndimage import uniform_filter1d 

32from scipy.signal import find_peaks 

33from scipy.stats import linregress 

34 

35 

36def SJM(*args, **kwargs): 

37 """Backwards compatibility ...""" 

38 if GPAW_NEW: 

39 from gpaw.new.ase_interface import GPAW 

40 from gpaw.new.sjm import SJM 

41 environment = SJM(cavity=kwargs.pop('cavity'), 

42 dielectric=kwargs.pop('dielectric'), 

43 interactions=kwargs.pop('interactions', None), 

44 **kwargs.pop('sj')) 

45 return GPAW( 

46 *args, **kwargs, 

47 environment=environment) 

48 return OldSJM(*args, **kwargs) 

49 

50 

51class OldSJM(OldSolvationGPAW): 

52 r"""Solvated Jellium method. 

53 (Implemented as a subclass of the SolvationGPAW class.) 

54 

55 The method allows the simulation of an electrochemical environment, where 

56 the potential can be varied by changing the charging (that is, number of 

57 electrons) of the system. For this purpose, it allows the usagelof non- 

58 neutral periodic slab systems. Cell neutrality is achieved by adding a 

59 background charge in the solvent region above the slab 

60 

61 Further details are given in https://doi.org/10.1021/acs.jpcc.8b02465 

62 If you use this method, we appreciate it if you cite that work. 

63 

64 The method can be run in three modes: 

65 

66 - Constant charge: The number of excess electrons in the simulation 

67 can be directly specified with the 'excess_electrons' keyword, 

68 leaving 'target_potential' set to None. 

69 - Constant potential: The target potential (expressed as a work 

70 function) can be specified with the 'target_potential' keyword. 

71 Optionally, the 'excess_electrons' keyword can be supplied to specify 

72 the initial guess of the number of electrons. 

73 - Constant inner potential: The target potential (expressed as a inner 

74 potential) can be specified with the method='CIP' and 

75 'target_potential' keywords. The CIP-DFT method is detailled in 

76 https://doi.org/10.1038/s41524-023-01184-4 

77 

78 By default, this method writes the grand-potential energy to the output; 

79 that is, the energy that has been adjusted with `- \mu N` (in this case, 

80 `\mu` is the work function and `N` is the excess electrons). This is the 

81 energy that is compatible with the forces in constant-potential mode and 

82 thus will behave well with optimizers, NEBs, etc. It is also frequently 

83 used in subsequent free-energy calculations. 

84 

85 Within this method, the potential is expressed as the top-side work 

86 function of the slab or the inner potential of the electrode. 

87 In both cases, a potential of 0 V_SHE corresponds to target_potential of 

88 roughly 4.4 eV. (That is, the user should specify 

89 target_potential as 4.4 in this case.) Because this method is 

90 attempting to bring either the work function or the inner potential to a 

91 target value, the work function and electrostatics need to be 

92 well-converged. For this reason, the 'work function' keyword is 

93 automatically added to the SCF convergence dictionary with a value of 

94 0.001. This can be overriden by the user. 

95 

96 All methods requires a dipole correction, and this is turned on 

97 automatically, but can be overridden with the poissonsolver keyword. 

98 

99 When using method='CIP', mixed Dirichlet/Neumann boundary conditions 

100 for the electrostatic potential are used by default. 

101 

102 The SJM class takes a single argument, the sj dictionary. All other 

103 arguments are fed to the parent SolvationGPAW (and therefore GPAW) 

104 calculator. 

105 

106 Parameters: 

107 

108 sj: dict 

109 Dictionary of parameters for the solvated jellium method, whose 

110 possible keys are given below. 

111 

112 Parameters in sj dictionary: 

113 

114 excess_electrons: float 

115 Number of electrons added in the atomic system and (with opposite 

116 sign) in the background charge region. If the 'target_potential' 

117 keyword is also supplied, 'excess_electrons' is taken as an initial 

118 guess for the needed number of electrons. 

119 target_potential: float 

120 The potential that should be reached or kept in the course of the 

121 calculation. If set to 'None' (default) a constant-charge 

122 calculation based on the value of 'excess_electrons' is performed. 

123 Expressed as a work function, on the top side of the slab; see note 

124 above. 

125 tol: float 

126 Tolerance for the deviation of the target potential. 

127 If the potential is outside the defined range 'ne' will be 

128 changed in order to get inside again. Default: 0.01 V. 

129 max_iters: int 

130 In constant-potential mode, the maximum number of iterations to try 

131 to equilibrate the potential (by varying ne). Default: 10. 

132 jelliumregion: dict 

133 Parameters regarding the shape of the counter charge region. 

134 Implemented keys: 

135 

136 'top': float or None 

137 Upper boundary of the counter charge region (z coordinate). 

138 A positive float is taken as the upper boundary coordiate. 

139 A negative float is taken as the distance below the uppper 

140 cell boundary. Default: -1. 

141 'bottom': float or 'cavity_like' or None 

142 Lower boundary of the counter-charge region (z coordinate). 

143 A positive float is taken as the lower boundary coordinate. 

144 A negative float is interpreted as the distance above the highest 

145 z coordinate of any atom; this can be fixed by setting 

146 fix_bottom to True. If 'cavity_like' is given the counter 

147 charge will take the form of the cavity up to the 'top'. 

148 Default: -3. 

149 'thickness': float or None 

150 Thickness of the counter charge region in Angstroms. 

151 Can only be used if start is not 'cavity_like'. Default: None 

152 'fix_bottom': bool 

153 Do not allow the lower limit of the jellium region to adapt 

154 to a change in the atomic geometry during a relaxation. 

155 Omitted in a 'cavity_like' jelliumregion. 

156 Default: False 

157 grand_output: bool 

158 Write the grand-potential energy into output files such as 

159 trajectory files. Default: True 

160 always_adjust: bool 

161 Adjust ne again even when potential is within tolerance. 

162 This is useful to set to True along with a loose potential 

163 tolerance (tol) to allow the potential and structure to be 

164 simultaneously optimized in a geometry optimization, for example. 

165 Default: False. 

166 slope : float or None 

167 Initial guess of the slope, in volts per electron, of the relationship 

168 between the electrode potential and excess electrons, used to 

169 equilibrate the potential. 

170 This only applies to the first step, as the slope is calculated 

171 internally at subsequent steps. If None, will be guessed based on 

172 apparent capacitance of 10 uF/cm^2. 

173 max_step : float 

174 When equilibrating the potential, if a step results in the 

175 potential being further from the target and changing by more than 

176 'max_step' V, then the step size is cut in half and we try again. This 

177 is to avoid leaving the linear region. You can set to np.inf to turn 

178 this off. Default: 2 V. 

179 mixer : float 

180 Damping for the slope estimate. Because estimating slopes can 

181 sometimes be noisy (particularly for small changes, or when 

182 positions have also changed), some fraction of the previous slope 

183 estimate can be "mixed" with the current slope estimate before the 

184 new slope is established. E.g., new_slope = mixer * old_slope + 

185 (1. - mixer) * current_slope_estimate. Set to 0 for no damping. 

186 Default: 0.5. 

187 fdt : bool or dict 

188 Keyboard for switching on/off the computation of the charge using 

189 the fluctuation dissipation theorem 

190 Default: False. 

191 If set to True, use standard parameters for the FDT calculation. 

192 If dict, the following keys are implemented: 

193 

194 'dt': float 

195 Time step for the FDT calculation in fs. Default: 0.5. 

196 'po_time': float 

197 Relaxation-time constant of potentiostat. Default: 100. 

198 'th_temp': float 

199 Thermal temperature for the FDT calculation in K. Default: 300. 

200 

201 slope_regression_depth : int 

202 Number of previous attempts to use for the slope regression. 

203 Default: 4. 

204 pot_ref: 'wf' or 'CIP' 

205 potential reference scale 

206 wf: original SJM using workfunction for the absolute potential 

207 CIP: use inner potential as the abs. el. pot. 

208 cip: dict, parameters when using CIP-DFT 

209 inner_region: list of floats: [bottom, top] 

210 bottom is the starting point for computing the inner potential, 

211 likewise top is the ending point. 

212 filter: int 

213 number of points for smoothing the el.stat. pot. 

214 autoinner: dict with {'nlayers':int, 'threshold':0.001} 

215 if inner_region is given, autoinner is automatically disabled 

216 nlayers: number of layers 

217 threshold: Required threshold of peaks, the innerpotential 

218 difference at neighboring grid points 

219 mu_pzc: float 

220 Fermi level at potential of zero charge 

221 Sets the reference scale for the absolute potential level for CIP 

222 phi_pzc: float 

223 Use only with method='CIP', the value corresponds to the inner 

224 potential of the neutral electrode 

225 

226 Special SJM methods (in addition to those of GPAW/SolvationGPAW): 

227 

228 get_electrode_potential 

229 Returns the potential of the simulated electrode, in V, relative 

230 to the vacuum. 

231 

232 write_sjm_traces 

233 Write traces of quantities like electrostatic potential or cavity 

234 to disk. 

235 

236 """ 

237 implemented_properties = ['energy', 'forces', 'stress', 'dipole', 

238 'magmom', 'magmoms', 'excess_electrons', 

239 'electrode_potential'] 

240 _sj_default_parameters = Parameters( 

241 {'excess_electrons': 0., 

242 'jelliumregion': {'top': -1., 

243 'bottom': -3., 

244 'thickness': None, 

245 'fix_bottom': False}, 

246 'target_potential': None, 

247 'pot_ref': 'wf', 

248 'tol': 0.01, 

249 'always_adjust': False, 

250 'grand_output': True, 

251 'max_iters': 10, 

252 'max_step': 2., 

253 'slope': None, 

254 'mixer': 0.5, 

255 'fdt': False, 

256 'previous_electrons': [], 

257 'previous_potentials': [], 

258 'slope_regression_depth': 4, 

259 'cip': {'autoinner': {'nlayers': None, 

260 'threshold': 0.0001}, 

261 'inner_region': None, 

262 'mu_pzc': None, 

263 'phi_pzc': None, 

264 'filter': 10}}) 

265 

266 _sj_default_parameters.update({'dirichlet': False}) 

267 default_parameters = copy.deepcopy(OldSolvationGPAW.default_parameters) 

268 default_parameters.update({'poissonsolver': {'dipolelayer': 'xy'}}) 

269 default_parameters['convergence'].update({'work function': 0.001}) 

270 default_parameters.update({'sj': _sj_default_parameters}) 

271 

272 def __init__(self, restart=None, **kwargs): 

273 

274 deprecated_keys = ['ne', 'potential', 'write_grandcanonical_energy', 

275 'potential_equilibration_mode', 'dpot', 

276 'max_pot_deviation', 'doublelayer', 'verbose'] 

277 msg = ('{:s} is no longer a supported keyword argument for the SJM ' 

278 'class. All SJM arguments should be sent in via the "sj" ' 

279 'dict.') 

280 for key in deprecated_keys: 

281 if key in kwargs: 

282 raise InputError(textwrap.fill(msg.format(key))) 

283 

284 # Note the below line calls self.set(). 

285 OldSolvationGPAW.__init__(self, restart, **kwargs) 

286 

287 def set(self, **kwargs): 

288 """Change parameters for calculator. 

289 

290 It differs from the standard `set` function in two ways: 

291 - Keywords in the `sj` dictionary are handled. 

292 - It does not reinitialize and delete `self.wfs` if only the 

293 background charge is changed. 

294 

295 """ 

296 p = self.parameters['sj'] 

297 

298 # We handle 'sj' and 'background_charge' internally; passing to GPAW's 

299 # set function will trigger a deletion of the density, etc. 

300 sj_changes = kwargs.pop('sj', {}) 

301 try: 

302 sj_changes = {key: value for key, value in sj_changes.items() 

303 if not equal(value, p[key])} 

304 except KeyError: 

305 raise InputError( 

306 'Unexpected key(s) provided to sj dict. ' 

307 'Keys provided were "{}". ' 

308 'Only keys allowed are "{}".' 

309 .format(', '.join(sj_changes), 

310 ', '.join(self.default_parameters['sj']))) 

311 self.fill_cip_keywords(p.cip) 

312 

313 if p.pot_ref == 'CIP': 

314 p['dirichlet'] = True 

315 

316 if p.cip['mu_pzc'] is None or p.cip['phi_pzc'] is None: 

317 p.cip['mu_pzc'] = 0 

318 p.cip['phi_pzc'] = 0 

319 msg = ('Warning: a CIP calculation has been activated ' 

320 'but mu_pzc and/or phi_pzc was none. This is fine ' 

321 'for CIP calibration but meaningful references ' 

322 'must be provided for production calculations\n') 

323 

324 if p.cip['inner_region'] is None and p.cip['autoinner'] is None: 

325 raise RuntimeError("The inner region cannot be none" + 

326 "when using inner potential as the" + 

327 "reference. Please, set up" + 

328 "either bottom/top values to define the" + 

329 "electrode bulk or set autoinner to True") 

330 

331 if p.cip.get('inner_region') and p.cip.get('autoinner'): 

332 raise RuntimeError("Only inner_region or autoinner" + 

333 "can be set to define the inner potential") 

334 

335 if p.cip['inner_region'] is not None: 

336 p.cip['inner_region'] = np.array(p.inner_region) 

337 p.cip['autoinner'] = None 

338 else: 

339 assert p.cip['autoinner']['nlayers'] is not None 

340 

341 p.update(sj_changes) 

342 

343 background_charge = kwargs.pop('background_charge', None) 

344 kwargs['_set_ok'] = True 

345 OldSolvationGPAW.set(self, **kwargs) 

346 

347 # parent_changed checks if GPAW needs to be reinitialized 

348 # The following key do not need reinitialization 

349 parent_changed = False 

350 for key in kwargs: 

351 if key not in ['mixer', 'verbose', 'txt', 'hund', 'random', 

352 'eigensolver', 'convergence', 'fixdensity', 

353 'maxiter', '_set_ok']: 

354 parent_changed = True 

355 

356 if len(sj_changes): 

357 if self.wfs is None: 

358 self.log('Non-default Solvated Jellium parameters:') 

359 else: 

360 self.log('Changed Solvated Jellium parameters:') 

361 self.log.print_dict({i: p[i] for i in sj_changes}) 

362 self.log() 

363 

364 if 'target_potential' in sj_changes and p.target_potential is not None: 

365 # If target potential is changed by the user and the slope is 

366 # known, a step towards the new potential is taken right away. 

367 try: 

368 true_potential = self.get_electrode_potential() 

369 # TypeError is needed for the case of starting from a gpw 

370 # file and changing the target potential at the start. 

371 except (AttributeError, TypeError): 

372 pass 

373 else: 

374 if self.atoms and p.slope: 

375 

376 p.excess_electrons += ((p.target_potential - 

377 true_potential) / p.slope) 

378 self.log('Number of electrons changed to {:.4f} based ' 

379 'on slope of {:.4f} V/electron.' 

380 .format(p.excess_electrons, p.slope)) 

381 

382 if (any(key in ['target_potential', 'excess_electrons', 

383 'jelliumregion'] for key in sj_changes) and 

384 not parent_changed): 

385 self.results = {} 

386 # SolvationGPAW will not reinitialize anymore if only 

387 # 'sj' keywords are set. The lines below will reinitialize and 

388 # apply the changed charges. 

389 if self.atoms: 

390 self.set(background_charge=self._create_jellium()) 

391 

392 if 'tol' in sj_changes: 

393 try: 

394 true_potential = self.get_electrode_potential() 

395 except (AttributeError, TypeError): 

396 pass 

397 else: 

398 msg = ('Potential tolerance changed to {:1.4f} V: ' 

399 .format(p.tol)) 

400 if abs(true_potential - p.target_potential) > p.tol: 

401 msg += 'new calculation required.' 

402 self.results = {} 

403 if self.atoms and p.slope is not None: 

404 p.excess_electrons += ((p.target_potential - 

405 true_potential) / p.slope) 

406 self.set(background_charge=self._create_jellium()) 

407 msg += ('\n Excess electrons changed to {:.4f} based ' 

408 'on slope of {:.4f} V/electron.' 

409 .format(p.excess_electrons, p.slope)) 

410 else: 

411 msg += 'already within tolerance.' 

412 self.log(msg) 

413 

414 if background_charge: 

415 # background_charge is a GPAW parameter that we handle internally, 

416 # as it contains the jellium countercharge. Note if a user tries to 

417 # specify an *additional* background charge this will probably 

418 # conflict, but we know of no such use cases. 

419 if self.wfs is None: 

420 kwargs.update({'background_charge': background_charge, 

421 '_set_ok': True}) 

422 OldSolvationGPAW.set(self, **kwargs) 

423 else: 

424 if parent_changed: 

425 self.density = None 

426 else: 

427 if self.density.background_charge: 

428 self.density.background_charge = background_charge 

429 self.density.background_charge.set_grid_descriptor( 

430 self.density.finegd) 

431 self._quick_reinitialization() 

432 

433 self.wfs.nvalence = self.setups.nvalence + p.excess_electrons 

434 self.log('Number of valence electrons is now {:.5f}' 

435 .format(self.wfs.nvalence)) 

436 

437 if self.parameters['sj']['fdt'] is True: 

438 # Default parameters if fdt is True 

439 self.parameters['sj']['fdt'] = { 

440 'dt': 0.5, 

441 'po_time': 100.0, 

442 'th_temp': 300.0} 

443 elif isinstance(self.parameters['sj']['fdt'], dict): 

444 # If fdt is a dict, ensure the dictionary is complete 

445 fdt_dict = self.parameters['sj']['fdt'] 

446 self.parameters['sj']['fdt'] = { 

447 'dt': fdt_dict.get('dt', 0.5), 

448 'po_time': fdt_dict.get('po_time', 100.0), 

449 'th_temp': fdt_dict.get('th_temp', 300.0)} 

450 

451 def _quick_reinitialization(self): 

452 """Minimal reinitialization of electronic-structure stuff when only 

453 background charge changes.""" 

454 if self.density.nct_G is None: 

455 self.initialize_positions() 

456 

457 self.density.reset() 

458 self._set_atoms(self.atoms) 

459 self.density.mixer.reset() 

460 self.wfs.initialize(self.density, self.hamiltonian, 

461 self.spos_ac) 

462 self.wfs.eigensolver.reset() 

463 if self.scf: 

464 self.scf.reset() 

465 

466 def calculate(self, atoms=None, properties=['energy'], 

467 system_changes=['cell']): 

468 """Perform an electronic structure calculation, with either a 

469 constant number of electrons or a target potential, as requested by 

470 the user in the 'sj' dict.""" 

471 

472 if atoms and not self.atoms: 

473 # Need to be set before ASE's Calculator.calculate gets to it. 

474 self.atoms = atoms.copy() 

475 

476 if len(system_changes) == 0 and len(self.results) > 0: 

477 # Potential is already equilibrated. 

478 OldSolvationGPAW.calculate(self, atoms, properties, system_changes) 

479 return 

480 

481 self.log('Solvated jellium method (SJM) calculation:') 

482 

483 p = self.parameters['sj'] 

484 

485 if p.target_potential is None: 

486 self.log('Constant-charge calculation with {:.5f} excess ' 

487 'electrons'.format(p.excess_electrons)) 

488 # Background charge is set here, not earlier, because atoms needed. 

489 self.set(background_charge=self._create_jellium()) 

490 OldSolvationGPAW.calculate(self, atoms, ['energy'], system_changes) 

491 self.log('Potential found to be {:.5f} V (with {:+.5f} ' 

492 'electrons)'.format(self.get_electrode_potential(), 

493 p.excess_electrons)) 

494 else: 

495 self.log(' Constant-potential mode.') 

496 self.log(' Target potential: {:.5f} +/- {:.5f}' 

497 .format(p.target_potential, p.tol)) 

498 self.log(' Initial guess of excess electrons: {:.5f}' 

499 .format(p.excess_electrons)) 

500 if 'workfunction' in self.parameters.convergence: 

501 if self.parameters.convergence['workfunction'] >= p.tol: 

502 msg = ('Warning: it appears that your work function ' 

503 'convergence criterion ({:g}) is higher than your ' 

504 'desired potential tolerance ({:g}). This may lead ' 

505 'to issues with potential convergence.' 

506 .format(self.parameters.convergence['workfunction'], 

507 p.tol)) 

508 self.log(textwrap.fill(msg)) 

509 self._equilibrate_potential(atoms, system_changes) 

510 if properties != ['energy']: 

511 # The equilibration loop only calculated energy, to save 

512 # unnecessary computations (mostly of forces) in the loop. 

513 OldSolvationGPAW.calculate(self, atoms, properties, []) 

514 

515 # Note that grand-potential energies were assembled in summary, 

516 # which in turn was called by GPAW.calculate. 

517 

518 if p.grand_output: 

519 self.results['energy'] = self.omega_extrapolated * Ha 

520 self.results['free_energy'] = self.omega_free * Ha 

521 self.log('Grand-potential energy was written into results.\n') 

522 else: 

523 self.log('Canonical energy was written into results.\n') 

524 

525 self.results['excess_electrons'] = p.excess_electrons 

526 self.results['electrode_potential'] = self.get_electrode_potential() 

527 self.log.fd.flush() 

528 

529 def _equilibrate_potential(self, atoms, system_changes): 

530 """Adjusts the number of electrons until the potential reaches the 

531 desired value.""" 

532 p = self.parameters['sj'] 

533 iteration = 0 

534 

535 rerun = False 

536 while iteration <= p.max_iters: 

537 self.log('Attempt {:d} to equilibrate potential to {:.3f} +/-' 

538 ' {:.3f} V' 

539 .format(iteration, p.target_potential, p.tol)) 

540 self.log('Current guess of excess electrons: {:+.5f}\n' 

541 .format(p.excess_electrons)) 

542 if iteration == 1: 

543 self.timer.start('Potential equilibration loop') 

544 # We don't want SolvationGPAW to see any more system 

545 # changes, like positions, after attempt 0. 

546 system_changes = [] 

547 

548 if any([iteration, rerun, 'positions' in system_changes]): 

549 self.set(background_charge=self._create_jellium()) 

550 

551 # Do the calculation. 

552 OldSolvationGPAW.calculate(self, atoms, ['energy'], system_changes) 

553 true_potential = self.get_electrode_potential() 

554 self.log() 

555 msg = (f'Potential found to be {true_potential:.5f} V (with ' 

556 f'{p.excess_electrons:+.5f} excess electrons, attempt ' 

557 f'{iteration:d}/{p.max_iters:d}') 

558 msg += ' rerun).' if rerun else ').' 

559 self.log(msg, flush=True) 

560 

561 # Check if we took too big of a step, that moved us further 

562 # from the target potential rather than closer. This can 

563 # happen when large geometric changes happened in the 

564 # last step, the slope is noisy or, at very low 

565 # workfunctions, where the electron density starts to spill out 

566 # into the vacuum and the slope is unreliable. 

567 # The rerun can happen multiple times if needed and the stepsize 

568 # will be reduced by factor of 2 every time. 

569 # The rerun is disabled if the FDT is used. 

570 

571 if len(p.previous_potentials): 

572 

573 stepsize = abs(true_potential - p.previous_potentials[-1]) 

574 

575 if (stepsize > p.max_step and 

576 abs(p.previous_potentials[-1] - p.target_potential) < 

577 abs(true_potential - p.target_potential)) and not p.fdt: 

578 self.log('Step resulted in a potential change of ' 

579 f'{stepsize:.2f} V, larger than max_step ' 

580 f'({p.max_step:.2f} V) and\n surpassed the' 

581 ' target potential by a dangerous amount.\n' 

582 ' The step is rejected and the change in' 

583 ' excess_electrons will be halved.') 

584 

585 if p.fdt: 

586 rerun = False 

587 else: 

588 pe, ce = p.previous_electrons[-1], p.excess_electrons 

589 if abs(pe - ce) < 1e-5: 

590 msg = ('Step size is too small to be halved in ' 

591 'rerun. To avoid this try to change your ' 

592 'initial guess of excess electrons. ' 

593 'Potential equilibration failed.') 

594 raise PotentialConvergenceError(msg) 

595 

596 p.excess_electrons = (pe + ce) / 2. 

597 

598 rerun = True 

599 continue # back to while 

600 

601 # Increase iteration count. 

602 iteration += 1 

603 rerun = False 

604 

605 # Store attempt and calculate slope. 

606 p.previous_electrons.append(float(p.excess_electrons)) 

607 p.previous_potentials.append(float(true_potential)) 

608 

609 # The following solves a bug, where the code would crash if the 

610 # user sets the right number of electrons to reach the target 

611 # potential in the first iteration and then changes the target 

612 # potential. The code would crash because the slope has not been 

613 # calculated yet and so no step is taken towards the new potential. 

614 # As two equal charges are added to p.previous_electrons, the 

615 # regression of the slope will fail. 

616 if len(p.previous_electrons) > 1: 

617 if not p.previous_electrons[-2] - p.previous_electrons[-1]: 

618 del p.previous_electrons[-2], p.previous_potentials[-2] 

619 

620 if len(p.previous_electrons) > 1: 

621 slope = _calculate_slope(p.previous_electrons, 

622 p.previous_potentials, 

623 p.slope_regression_depth) 

624 

625 nreg = len(p.previous_electrons[-p.slope_regression_depth:]) 

626 self.log(f'Slope regressed from last {nreg:d} attempts is ' 

627 f'{slope:.4f} V/electron,') 

628 area = np.linalg.det(atoms.cell[:2, :2]) 

629 # get capacitance in muF/cm^2 

630 capacitance = - _e * 1e22 / (area * slope) 

631 self.log(f'or apparent capacitance of {capacitance:.4f} ' 

632 'muF/cm^2') 

633 

634 if p.slope is not None: 

635 p.slope = p.mixer * p.slope + (1. - p.mixer) * slope 

636 self.log(f'After mixing with {p.mixer:.2f}, new slope is ' 

637 f'{p.slope:.4f} V/electron.') 

638 else: 

639 p.slope = slope 

640 

641 self.log.flush() 

642 

643 # Check if we're equilibrated and exit if always_adjust is False. 

644 if abs(true_potential - p.target_potential) < p.tol and not p.fdt: 

645 self.log('Potential is within tolerance. Equilibrated.') 

646 if iteration >= 2: 

647 self.timer.stop('Potential equilibration loop') 

648 if not p.always_adjust: 

649 return 

650 

651 # Guess slope if we don't have enough information yet. 

652 if p.slope is None or (p.slope > 0. and p.fdt): 

653 area = np.linalg.det(atoms.cell[:2, :2]) 

654 p.slope = -1.6022e3 / (area * 10.) 

655 if p.fdt: 

656 self.log('Positive slope! Guessing a slope of ' 

657 f'{p.slope:.4f} corresponding\nto an apparent ' 

658 'capacitance of 10 muF/cm^2.') 

659 else: 

660 self.log('No slope provided, guessing a slope of ' 

661 f'{p.slope:.4f} corresponding\nto an apparent ' 

662 'capacitance of 10 muF/cm^2.') 

663 

664 if p.fdt: 

665 fdt_dict = p['fdt'] 

666 dt = fdt_dict['dt'] 

667 po_time = fdt_dict['po_time'] 

668 th_temp = fdt_dict['th_temp'] 

669 

670 rn = np.random.standard_normal(1) 

671 

672 self.world.broadcast(rn, 0) 

673 # set capacitance again 

674 area = np.linalg.det(atoms.cell[:2, :2]) 

675 if abs(p.slope) < 1e-10: 

676 raise ValueError( 

677 "Slope cannot be zero when calculating capacitance.") 

678 

679 capacitance = - _e * 1e22 / (area * p.slope) 

680 

681 p.excess_electrons += ( 

682 capacitance * (true_potential - p.target_potential) 

683 * (1 - np.exp(-dt / po_time)) 

684 + rn[0] * np.sqrt( 

685 kB * th_temp * capacitance 

686 * (1 - np.exp(-2 * dt / po_time)) 

687 ) 

688 ) 

689 self.log( 

690 f'Number of electrons is {p.excess_electrons:.4f} ' 

691 f'using the FDT, with slope of {p.slope:.4f} V/electron ' 

692 f'and capacitance of ({capacitance:.4f} muF/cm2).' 

693 ) 

694 

695 else: 

696 p.excess_electrons += ((p.target_potential - true_potential) 

697 / p.slope) 

698 self.log( 

699 f'Number of electrons changed to {p.excess_electrons:.4f}' 

700 f' based on slope of {p.slope:.4f} V/electron.') 

701 

702 # Check if we're equilibrated and exit if always_adjust is True. 

703 if (abs(true_potential - p.target_potential) < p.tol 

704 and p.always_adjust) or p.fdt: 

705 return 

706 

707 msg = (f'Potential could not be reached after {iteration - 1:d} ' 

708 'iterations. This may indicate your workfunction is noisier ' 

709 'than your potential tol. You may try setting the ' 

710 'convergence["workfunction"] keyword. The last values of ' 

711 'excess_electrons and the potential are listed below; ' 

712 'plotting them could give you insight into the problem.') 

713 msg = textwrap.fill(msg) + '\n' 

714 for n, p in zip(p.previous_electrons, p.previous_potentials): 

715 msg += f'{n:+.6f} {p:.6f}\n' 

716 self.log(msg, flush=True) 

717 raise PotentialConvergenceError(msg) 

718 

719 def write_sjm_traces(self, path='sjm_traces', style='z', 

720 props=('potential', 'cavity', 'background_charge')): 

721 """Write traces of quantities in `props` to file on disk; traces will 

722 be stored within specified path. Default is to save as vertical traces 

723 (style 'z'), but can also save as cube (specify `style='cube'`).""" 

724 grid = self.density.finegd 

725 data = {'cavity': self.hamiltonian.cavity.g_g, 

726 'background_charge': self.density.background_charge.mask_g, 

727 'potential': (self.hamiltonian.vHt_g * Ha - 

728 self.get_fermi_level())} 

729 if not os.path.exists(path) and gpaw.mpi.world.rank == 0: 

730 os.makedirs(path) 

731 for prop in props: 

732 if style == 'z': 

733 _write_trace_in_z(grid, data[prop], prop + '.txt', path) 

734 elif style == 'cube': 

735 _write_property_on_grid(grid, data[prop], self.atoms, 

736 prop + '.cube', path) 

737 

738 def summary(self): 

739 """Writes summary information to the log file. 

740 This varies from the implementation in gpaw.calculator.GPAW by the 

741 inclusion of grand potential quantities.""" 

742 # Standard GPAW summary. 

743 self.hamiltonian.summary(self.wfs, self.log) 

744 # Add grand-canonical terms. 

745 p = self.parameters['sj'] 

746 self.log() 

747 mu_N = -self.get_electrode_potential() 

748 mu_N *= p.excess_electrons / Ha 

749 self.omega_free = self.hamiltonian.e_total_free - mu_N 

750 self.omega_extrapolated = self.hamiltonian.e_total_extrapolated - mu_N 

751 self.log('Legendre-transformed energies (grand potential, ' 

752 'Omega = E - N mu)') 

753 self.log(' N (excess electrons): {:+11.6f}' 

754 .format(p.excess_electrons)) 

755 if p['pot_ref'] == 'wf': 

756 self.log(' mu (-workfunction, eV): {:+11.6f}' 

757 .format(-self.get_electrode_potential())) 

758 elif p['pot_ref'] == 'CIP': 

759 self.log('Electrode potential from inner potential: {:+11.6f} [eV]' 

760 .format(self.get_electrode_potential())) 

761 self.log('The absolute inner potential is: {:+11.6f} [eV]' 

762 .format(self.get_inner_potential(self.atoms, 

763 p['cip']['inner_region']))) 

764 

765 self.log(' (Grand) free energy: {:+11.6f}' 

766 .format(Ha * self.omega_free)) 

767 self.log(' (Grand) extrapolated: {:+11.6f}' 

768 .format(Ha * self.omega_extrapolated)) 

769 self.log() 

770 # Back to standard GPAW summary. 

771 self.density.summary(self.atoms, self.results.get('magmom', 0.0), 

772 self.log) 

773 self.wfs.summary(self.log) 

774 self._print_gapinfo() 

775 self.log.fd.flush() 

776 

777 def _create_jellium(self): 

778 """Creates the counter charge according to the user's specs.""" 

779 atoms = self.atoms 

780 

781 p = self.parameters['sj'] 

782 jellium = p['jelliumregion'] 

783 defaults = self.default_parameters['sj']['jelliumregion'] 

784 

785 # Populate missing keywords. 

786 missing = {'top': None, 

787 'bottom': None, 

788 'thickness': None, 

789 'fix_bottom': False} 

790 for key in missing: 

791 if key not in jellium: 

792 jellium[key] = missing[key] 

793 

794 # Catch incompatible specifications. 

795 if jellium.get('thickness') and jellium['bottom'] == 'cavity_like': 

796 raise InputError("With a cavity-like counter charge only the " 

797 "keyword 'top' (not 'thickness') allowed.") 

798 

799 # We need 2 of the 3 "specifieds" below. 

800 specifieds = [jellium['top'] is not None, 

801 jellium['bottom'] is not None, 

802 jellium['thickness'] is not None] 

803 

804 if sum(specifieds) == 3: 

805 raise InputError('The jellium region has been overspecified.') 

806 if sum(specifieds) == 0: 

807 top = defaults['top'] 

808 bottom = defaults['bottom'] 

809 thickness = defaults['thickness'] 

810 if sum(specifieds) == 2: 

811 top = jellium['top'] 

812 bottom = jellium['bottom'] 

813 thickness = jellium['thickness'] 

814 if specifieds == [True, False, False]: 

815 top = jellium['top'] 

816 bottom = defaults['bottom'] 

817 thickness = None 

818 elif specifieds == [False, True, False]: 

819 top = defaults['top'] 

820 bottom = jellium['bottom'] 

821 thickness = None 

822 elif specifieds == [False, False, True]: 

823 top = None 

824 bottom = defaults['bottom'] 

825 thickness = jellium['thickness'] 

826 

827 # Deal with negative specifications of upper and lower limits; 

828 # as well as fixed/free lower limit. 

829 if top is not None and top < 0.: 

830 top = atoms.cell[2][2] + top 

831 if bottom not in [None, 'cavity_like'] and bottom < 0.: 

832 bottom = (max(atoms.positions[:, 2]) - bottom) 

833 if jellium['fix_bottom'] is True: 

834 try: 

835 bottom = self._fixed_lower_limit 

836 except AttributeError: 

837 self._fixed_lower_limit = bottom 

838 

839 # Use thickness if needed. 

840 if top is None: 

841 top = bottom + thickness 

842 if bottom is None: 

843 bottom = top - thickness 

844 

845 # Catch unphysical limits. 

846 if top > atoms.cell[2][2]: 

847 raise InputError('The upper limit of the jellium region lies ' 

848 'outside of unit cell. If you did not set it ' 

849 'manually, increase your unit cell size or ' 

850 'translate the atomic system down along the ' 

851 'z-axis.') 

852 if bottom != 'cavity_like': 

853 if bottom > top: 

854 raise InputError('Your jellium region has a bottom at {:.3f}' 

855 ' AA, which is above the top at {:.3f} AA.' 

856 .format(bottom, top)) 

857 

858 # Finally, make the jellium. 

859 if bottom == 'cavity_like': 

860 if self.hamiltonian is None: 

861 self.initialize(atoms) 

862 self.set_positions(atoms) 

863 g_g = self.hamiltonian.cavity.g_g.copy() 

864 self.wfs = None 

865 self.density = None 

866 self.hamiltonian = None 

867 self.initialized = False 

868 else: 

869 self.set_positions(atoms) 

870 # XXX If you start with a fixed bottom, run a calc, 

871 # then hot-switch to cavity_like it crashes here. Not sure 

872 # that's common enough to worry about. 

873 g_g = self.hamiltonian.cavity.g_g 

874 self.log('Jellium counter-charge defined with:\n' 

875 ' Bottom: cavity-like\n' 

876 ' Top: {:7.3f} AA\n' 

877 ' Charge: {:5.4f}\n' 

878 .format(top, p.excess_electrons)) 

879 return CavityShapedJellium(charge=p.excess_electrons, 

880 g_g=g_g, 

881 z2=top) 

882 

883 self.log('Jellium counter-charge defined with:\n' 

884 ' Bottom: {:7.3f} AA\n' 

885 ' Top: {:7.3f} AA\n' 

886 ' Charge: {:5.4f}\n' 

887 .format(bottom, top, p.excess_electrons)) 

888 return JelliumSlab(charge=p.excess_electrons, 

889 z1=bottom, 

890 z2=top) 

891 

892 def get_electrode_potential(self, pot_ref=None, 

893 return_referenced=True): 

894 """Returns the potential of the simulated electrode, in V, relative 

895 to the vacuum. This comes directly from the work function.""" 

896 

897 if pot_ref is None: 

898 pot_ref = self.parameters.sj['pot_ref'] 

899 

900 if pot_ref == 'wf': 

901 try: 

902 return Ha * self.hamiltonian.get_workfunctions(self.wfs)[1] 

903 except TypeError: 

904 # Error happens on freshly-opened *.gpw file. 

905 if 'electrode_potential' in self.results: 

906 return self.results['electrode_potential'] 

907 else: 

908 msg = ('Electrode potential could not be read. Make sure a' 

909 'DFT calculation has been performed before reading ' 

910 'the potential.') 

911 raise PropertyNotPresent(textwrap.fill(msg)) 

912 

913 elif pot_ref == 'CIP': 

914 cip = self.parameters.sj.cip 

915 inner_potential = self.get_inner_potential(self.atoms) 

916 if return_referenced is True: 

917 inner_potential = - (cip['mu_pzc'] - cip['phi_pzc'] + 

918 inner_potential) 

919 return inner_potential 

920 

921 return cip['phi_pzc'] - inner_potential 

922 

923 def get_inner_potential(self, atoms, z=None): 

924 

925 cip = self.parameters.sj.cip 

926 self.fill_cip_keywords(cip) 

927 

928 el = self.hamiltonian.vHt_g * Ha 

929 gd = self.hamiltonian.finegd 

930 elstat = gd.collect(el, broadcast=True) 

931 el_stat_z = elstat.mean(0).mean(0) 

932 smooth_elstat_z = uniform_filter1d(el_stat_z, size=cip['filter']) 

933 

934 if cip['inner_region'] is not None: 

935 z_top = (np.abs(gd.coords(2) - (z[1] / Bohr))).argmin() 

936 z_bottom = (np.abs(gd.coords(2) - (z[0] / Bohr))).argmin() 

937 el_av = np.average(smooth_elstat_z[z_bottom:z_top]) 

938 else: 

939 # find minima of elstat potentials (position of nuclei) 

940 peaks, _ = find_peaks(-smooth_elstat_z, 

941 threshold=cip['autoinner']['threshold']) 

942 # choose maxima of elstatpot using the number of layers 

943 if (cip['autoinner']['nlayers'] % 2) == 0: 

944 # even number of peaks 

945 b = int(cip['autoinner']['nlayers'] / 2) 

946 bottom = peaks[b - 1] 

947 top = peaks[b] 

948 el_av = np.average(smooth_elstat_z[bottom:top]) 

949 else: 

950 # odd number of peaks 

951 c = int((cip['autoinner']['nlayers'] - 1) / 2) 

952 bottom = peaks[c - 1] 

953 top = peaks[c + 1] 

954 el_av = np.average(smooth_elstat_z[bottom:top]) 

955 

956 return el_av 

957 

958 def fill_cip_keywords(self, cip): 

959 

960 defaults_auto = self.default_parameters['sj']['cip']['autoinner'] 

961 missing = {'nlayers': None, 

962 'threshold': None} 

963 for key in missing: 

964 if key not in cip['autoinner']: 

965 cip['autoinner'][key] = defaults_auto[key] 

966 

967 defaults_cip = self.default_parameters['sj']['cip'] 

968 missing = {'inner_region': None, 

969 'mu_pzc': None, 

970 'phi_pzc': None, 

971 'filter': None} 

972 

973 for key in missing: 

974 if key not in cip: 

975 cip[key] = defaults_cip[key] 

976 

977 def initialize(self, atoms=None, reading=False): 

978 """Inexpensive initialization. 

979 

980 This catches CavityShapedJellium, which GPAW's initialize does not 

981 know how to handle on restart. We delete and it will be recreated by 

982 the _create_jellium method when needed. 

983 """ 

984 background_charge = self.parameters['background_charge'] 

985 if isinstance(background_charge, dict): 

986 if 'z1' in background_charge: 

987 if background_charge['z1'] == 'cavity_like': 

988 self.parameters['background_charge'] = None 

989 OldSolvationGPAW.initialize(self=self, atoms=atoms, reading=reading) 

990 

991 def create_hamiltonian(self, realspace, mode, xc): 

992 """This differs from SolvationGPAW's create_hamiltonian method by the 

993 ability to use dipole corrections.""" 

994 if not realspace: 

995 raise NotImplementedError( 

996 'SJM does not support calculations in reciprocal space yet' 

997 ' due to a lack of an implicit solvent module.') 

998 

999 dens = self.density 

1000 

1001 self.hamiltonian = SJM_RealSpaceHamiltonian( 

1002 *self.stuff_for_hamiltonian, 

1003 gd=dens.gd, finegd=dens.finegd, 

1004 nspins=dens.nspins, 

1005 collinear=dens.collinear, 

1006 setups=dens.setups, 

1007 timer=self.timer, 

1008 xc=xc, 

1009 world=self.world, 

1010 redistributor=dens.redistributor, 

1011 vext=self.parameters.external, 

1012 psolver=self.parameters.poissonsolver, 

1013 stencil=mode.interpolation, 

1014 dirichlet=self.parameters['sj']['dirichlet']) 

1015 

1016 xc.set_grid_descriptor(self.hamiltonian.finegd) 

1017 

1018 

1019def _write_trace_in_z(grid, property, name, dir): 

1020 """Writes out a property (like electrostatic potential, cavity, or 

1021 background charge) as a function of the z coordinate only. `grid` is the 

1022 grid descriptor, typically self.density.finegd. `property` is the property 

1023 to be output, on the same grid.""" 

1024 property = grid.collect(property, broadcast=True) 

1025 property_z = property.mean(0).mean(0) 

1026 with paropen(os.path.join(dir, name), 'w') as f: 

1027 for i, val in enumerate(property_z): 

1028 f.write(f'{(i + 1) * grid.h_cv[2][2] * Bohr:f} {val:1.8f}\n') 

1029 

1030 

1031def _write_property_on_grid(grid, property, atoms, name, dir): 

1032 """Writes out a property (like electrostatic potential, cavity, or 

1033 background charge) on the grid, as a cube file. `grid` is the 

1034 grid descriptor, typically self.density.finegd. `property` is the property 

1035 to be output, on the same grid.""" 

1036 property = grid.collect(property, broadcast=True) 

1037 ase.io.write(os.path.join(dir, name), atoms, data=property) 

1038 

1039 

1040def _calculate_slope(previous_electrons, previous_potentials, n_prev_pot): 

1041 """Calculates the slope of potential versus number of electrons; 

1042 regresses based on (up to) last four data points to smooth noise.""" 

1043 # debug 

1044 

1045 ans = linregress(previous_electrons[-n_prev_pot:], 

1046 previous_potentials[-n_prev_pot:]) 

1047 return ans[0] 

1048 

1049 

1050class SJMPower12Potential(Power12Potential): 

1051 r"""Inverse power-law potential. 

1052 Inverse power law potential for SJM, inherited from the 

1053 Power12Potential of gpaw.solvation. This is a 1/r^{12} repulsive 

1054 potential taking the value u0 at the atomic radius. In SJM one also has the 

1055 option of removing the solvent from the electrode backside and adding 

1056 ghost plane/atoms to remove the solvent from the electrode-water interface. 

1057 

1058 Parameters: 

1059 

1060 atomic_radii: function 

1061 Callable mapping an ase.Atoms object to an iterable of atomic radii 

1062 in Angstroms. If not provided, defaults to van der Waals radii. 

1063 u0: float 

1064 Strength of the potential at the atomic radius in eV. 

1065 Defaults to 0.18 eV, the best-fit value for water from Held & 

1066 Walter. 

1067 pbc_cutoff: float 

1068 Cutoff in eV for including neighbor cells in a calculation with 

1069 periodic boundary conditions. 

1070 H2O_layer: bool, int or str 

1071 True: Exclude the implicit solvent from the interface region 

1072 between electrode and water. Ghost atoms will be added below 

1073 the water layer. 

1074 False: The opposite of True. [default] 

1075 int: Explicitly account for the given number of water molecules above 

1076 electrode. This is handy if H2O is directly adsorbed and a water layer 

1077 is present in the unit cell at the same time. 

1078 'plane': Use a plane instead of ghost atoms for freeing the surface. 

1079 unsolv_backside: bool 

1080 Exclude implicit solvent from the region behind the electrode 

1081 

1082 """ 

1083 

1084 depends_on_el_density = False 

1085 depends_on_atomic_positions = True 

1086 

1087 def __init__(self, atomic_radii=None, u0=0.180, pbc_cutoff=1e-6, 

1088 tiny=1e-10, H2O_layer=False, 

1089 unsolv_backside=True, communicator=gpaw.mpi.world): 

1090 super().__init__(atomic_radii, u0, pbc_cutoff, tiny) 

1091 self.H2O_layer = H2O_layer 

1092 self.unsolv_backside = unsolv_backside 

1093 self.communicator = communicator 

1094 

1095 def todict(self): 

1096 return { 

1097 **super().todict(), 

1098 'H2O_layer': self.H2O_layer, 

1099 'unsolv_backside': self.unsolv_backside} 

1100 

1101 def __str__(self): 

1102 s = Power12Potential.__str__(self) 

1103 s += indent(f' H2O layer: {self.H2O_layer}\n') 

1104 s += indent(f' Only solvate front side: {self.unsolv_backside}\n') 

1105 return s 

1106 

1107 def write(self, writer): 

1108 writer.write( 

1109 name='SJMPower12Potential', 

1110 u0=self.u0, 

1111 atomic_radii=self.atomic_radii_output, 

1112 H2O_layer=self.H2O_layer, 

1113 unsolv_backside=self.unsolv_backside) 

1114 

1115 def update(self, atoms, density): 

1116 if atoms is None: 

1117 return False 

1118 self.r12_a = (self.atomic_radii_output / Bohr) ** 12 

1119 r_cutoff = (self.r12_a.max() * self.u0 / self.pbc_cutoff) ** (1. / 12.) 

1120 self.pos_aav = get_pbc_positions(atoms, r_cutoff) 

1121 self.u_g.fill(.0) 

1122 self.grad_u_vg.fill(.0) 

1123 na = np.newaxis 

1124 

1125 if self.unsolv_backside: 

1126 # Removing solvent from electrode backside 

1127 for z in range(self.u_g.shape[2]): 

1128 if (self.r_vg[2, 0, 0, z] - atoms.positions[:, 2].min() / 

1129 Bohr < 0): 

1130 self.u_g[:, :, z] = np.inf 

1131 self.grad_u_vg[:, :, :, z] = 0 

1132 

1133 if self.H2O_layer: 

1134 # Add ghost coordinates and indices to pos_aav dictionary if 

1135 # a water layer is present. 

1136 

1137 all_oxygen_ind = [atom.index for atom in atoms 

1138 if atom.symbol == 'O'] 

1139 

1140 # Disregard oxygens that don't belong to the water layer 

1141 allwater_oxygen_ind = [] 

1142 for ox in all_oxygen_ind: 

1143 nH = 0 

1144 

1145 for i, atm in enumerate(atoms): 

1146 for period_atm in self.pos_aav[i]: 

1147 dist = period_atm * Bohr - atoms[ox].position 

1148 if np.linalg.norm(dist) < 1.3 and atm.symbol == 'H': 

1149 nH += 1 

1150 

1151 if nH >= 2: 

1152 allwater_oxygen_ind.append(ox) 

1153 

1154 # If the number of waters in the water layer is given as an input 

1155 # (H2O_layer=i) then only the uppermost i water molecules are 

1156 # regarded for unsolvating the interface (this is relevant if 

1157 # water is adsorbed on the surface) 

1158 if not isinstance(self.H2O_layer, (bool, str)): 

1159 if self.H2O_layer % 1 < self.tiny: 

1160 self.H2O_layer = int(self.H2O_layer) 

1161 else: 

1162 raise InputError('Only an integer number of water ' 

1163 'molecules is possible in the water ' 

1164 'layer') 

1165 

1166 allwaters = atoms[allwater_oxygen_ind] 

1167 indizes_water_ox_ind = np.argsort(allwaters.positions[:, 2], 

1168 axis=0) 

1169 

1170 water_oxygen_ind = [] 

1171 for i in range(self.H2O_layer): 

1172 water_oxygen_ind.append( 

1173 allwater_oxygen_ind[indizes_water_ox_ind[-1 - i]]) 

1174 

1175 else: 

1176 water_oxygen_ind = allwater_oxygen_ind 

1177 

1178 oxygen = self.pos_aav[water_oxygen_ind[0]] * Bohr 

1179 if len(water_oxygen_ind) > 1: 

1180 for windex in water_oxygen_ind[1:]: 

1181 oxygen = np.concatenate( 

1182 (oxygen, self.pos_aav[windex] * Bohr)) 

1183 

1184 O_layer = [] 

1185 if isinstance(self.H2O_layer, str): 

1186 # Add a virtual plane 

1187 if len(self.H2O_layer.split('-')) > 1: 

1188 plane_z = float(self.H2O_layer.split('-')[1]) - \ 

1189 1.0 * self.atomic_radii_output[water_oxygen_ind[0]] 

1190 else: 

1191 plane_rel_oxygen = -1.5 * self.atomic_radii_output[ 

1192 water_oxygen_ind[0]] 

1193 plane_z = oxygen[:, 2].min() + plane_rel_oxygen 

1194 

1195 r_diff_zg = self.r_vg[2, :, :, :] - plane_z / Bohr 

1196 r_diff_zg[r_diff_zg < self.tiny] = self.tiny 

1197 r_diff_zg2 = r_diff_zg ** 2 

1198 u_g = self.r12_a[water_oxygen_ind[0]] / r_diff_zg2 ** 6 

1199 self.u_g += u_g.copy() 

1200 u_g /= r_diff_zg2 

1201 r_diff_zg *= u_g.copy() 

1202 self.grad_u_vg[2, :, :, :] += r_diff_zg 

1203 

1204 else: 

1205 # Ghost atoms are added below the explicit water layer 

1206 cell = atoms.cell.copy() / Bohr 

1207 cell[2][2] = 1. 

1208 natoms_in_plane = [round(np.linalg.norm(cell[0]) * 1.5), 

1209 round(np.linalg.norm(cell[1]) * 1.5)] 

1210 

1211 plane_z = (oxygen[:, 2].min() - 1.75 * 

1212 self.atomic_radii_output[water_oxygen_ind[0]]) 

1213 nghatoms_z = int(round(oxygen[:, 2].min() - 

1214 atoms.positions[:, 2].min())) 

1215 

1216 for i in range(int(natoms_in_plane[0])): 

1217 for j in range(int(natoms_in_plane[1])): 

1218 for k in np.linspace(atoms.positions[:, 2].min(), 

1219 plane_z, num=nghatoms_z): 

1220 

1221 O_layer.append(np.dot(np.array( 

1222 [(1.5 * i - natoms_in_plane[0] / 4) / 

1223 natoms_in_plane[0], 

1224 (1.5 * j - natoms_in_plane[1] / 4) / 

1225 natoms_in_plane[1], 

1226 k / Bohr]), cell)) 

1227 

1228 # Add additional ghost O-atoms below the actual water O atoms 

1229 # of water which frees the interface in case of corrugated 

1230 # water layers 

1231 for ox in oxygen / Bohr: 

1232 O_layer.append([ox[0], ox[1], ox[2] - 1.0 * 

1233 self.atomic_radii_output[ 

1234 water_oxygen_ind[0]] / Bohr]) 

1235 

1236 r12_add = [] 

1237 for i in range(len(O_layer)): 

1238 self.pos_aav[len(atoms) + i] = [O_layer[i]] 

1239 r12_add.append(self.r12_a[water_oxygen_ind[0]]) 

1240 r12_add = np.array(r12_add) 

1241 # r12_a must have same dimensions as pos_aav items 

1242 self.r12_a = np.concatenate((self.r12_a, r12_add)) 

1243 

1244 for index, pos_av in self.pos_aav.items(): 

1245 pos_av = np.array(pos_av) 

1246 r12 = self.r12_a[index] 

1247 for pos_v in pos_av: 

1248 origin_vg = pos_v[:, na, na, na] 

1249 r_diff_vg = self.r_vg - origin_vg 

1250 r_diff2_g = (r_diff_vg ** 2).sum(0) 

1251 r_diff2_g[r_diff2_g < self.tiny] = self.tiny 

1252 u_g = r12 / r_diff2_g ** 6 

1253 self.u_g += u_g 

1254 u_g /= r_diff2_g 

1255 r_diff_vg *= u_g[na, ...] 

1256 self.grad_u_vg += r_diff_vg 

1257 

1258 self.u_g *= self.u0 / Ha 

1259 self.grad_u_vg *= -12. * self.u0 / Ha 

1260 self.grad_u_vg[self.grad_u_vg < -1e20] = -1e20 

1261 self.grad_u_vg[self.grad_u_vg > 1e20] = 1e20 

1262 

1263 return True 

1264 

1265 

1266class SJM_RealSpaceHamiltonian(SolvationRealSpaceHamiltonian): 

1267 """Realspace Hamiltonian with continuum solvent model in the context of 

1268 SJM. 

1269 

1270 See also Section III of 

1271 A. Held and M. Walter, J. Chem. Phys. 141, 174108 (2014). 

1272 

1273 In contrast to the standard implicit solvent model a dipole correction can 

1274 also be applied; this is the only difference from its parent. 

1275 """ 

1276 

1277 def __init__(self, cavity, dielectric, interactions, gd, finegd, nspins, 

1278 setups, timer, xc, world, redistributor, vext=None, 

1279 psolver=None, stencil=3, collinear=None, dirichlet=False): 

1280 

1281 self.cavity = cavity 

1282 self.dielectric = dielectric 

1283 self.interactions = interactions 

1284 cavity.set_grid_descriptor(finegd) 

1285 dielectric.set_grid_descriptor(finegd) 

1286 for ia in interactions: 

1287 ia.set_grid_descriptor(finegd) 

1288 

1289 if psolver is None: 

1290 psolver = WeightedFDPoissonSolver() 

1291 self.dipcorr = False 

1292 elif isinstance(psolver, dict): 

1293 # Sadly the 'dipolelayer' cannot be pop'd because 

1294 # CavityShapedJellium calls this twice. 

1295 poi_par = {a: b for a, b in psolver.items() if a != 'dipolelayer'} 

1296 psolver = SJMDipoleCorrection(WeightedFDPoissonSolver(**poi_par), 

1297 psolver['dipolelayer'], 

1298 dirichlet=dirichlet) 

1299 self.dipcorr = True 

1300 

1301 if self.dipcorr: 

1302 psolver.poissonsolver.set_dielectric(self.dielectric) 

1303 else: 

1304 psolver.set_dielectric(self.dielectric) 

1305 

1306 self.gradient = None 

1307 

1308 RealSpaceHamiltonian.__init__( 

1309 self, 

1310 gd, finegd, nspins, collinear, setups, timer, xc, world, 

1311 vext=vext, psolver=psolver, 

1312 stencil=stencil, redistributor=redistributor) 

1313 

1314 for ia in interactions: 

1315 setattr(self, 'e_' + ia.subscript, None) 

1316 self.new_atoms = None 

1317 self.vt_ia_g = None 

1318 self.e_total_free = None 

1319 self.e_total_extrapolated = None 

1320 

1321 def initialize(self): 

1322 if self.dipcorr: 

1323 self.gradient = [Gradient(self.finegd, i, 1.0, 

1324 self.poisson.poissonsolver.nn) 

1325 for i in (0, 1, 2)] 

1326 else: 

1327 self.gradient = [Gradient(self.finegd, i, 1.0, 

1328 self.poisson.nn) 

1329 for i in (0, 1, 2)] 

1330 

1331 self.vt_ia_g = self.finegd.zeros() 

1332 self.cavity.allocate() 

1333 self.dielectric.allocate() 

1334 for ia in self.interactions: 

1335 ia.allocate() 

1336 RealSpaceHamiltonian.initialize(self) 

1337 

1338 

1339class CavityShapedJellium(Jellium): 

1340 """The jellium object, where the counter charge takes the form of the 

1341 solvent cavity. It puts the jellium background charge where the solvent is 

1342 present and z < z2. 

1343 

1344 Parameters: 

1345 ---------- 

1346 charge: float 

1347 The total jellium background charge. 

1348 g_g: array 

1349 The g function from the implicit solvent model, representing the 

1350 percentage of the actual dielectric constant on the grid. 

1351 z2: float 

1352 Position of upper surface in Angstrom units. 

1353 """ 

1354 

1355 def __init__(self, charge, g_g, z2): 

1356 

1357 Jellium.__init__(self, charge) 

1358 self.g_g = g_g 

1359 self.z2 = (z2 - 0.0001) / Bohr 

1360 

1361 def todict(self): 

1362 dct = Jellium.todict(self) 

1363 dct.update(z2=self.z2 * Bohr + 0.0001, 

1364 z1='cavity_like') 

1365 return dct 

1366 

1367 def get_mask(self): 

1368 r_gv = self.gd.get_grid_point_coordinates().transpose((1, 2, 3, 0)) 

1369 mask = np.logical_not(r_gv[:, :, :, 2] > self.z2).astype(float) 

1370 mask *= self.g_g 

1371 return mask 

1372 

1373 

1374class SJMDipoleCorrection(DipoleCorrection): 

1375 """Dipole-correcting wrapper around another PoissonSolver specific for SJM. 

1376 

1377 Iterative dipole correction class as applied in SJM. 

1378 

1379 Notes 

1380 ----- 

1381 

1382 The modules can easily be incorporated in the trunk version of GPAW 

1383 by just adding the `fd_solv_solve` and adapting the `solve` modules 

1384 in the `DipoleCorrection` class. 

1385 

1386 This module is currently calculating the correcting dipole potential 

1387 iteratively and we would be very grateful if anybody could 

1388 provide an analytical solution. 

1389 

1390 New Parameters 

1391 --------- 

1392 corrterm: float 

1393 Correction factor for the added countering dipole. This is calculated 

1394 iteratively. 

1395 

1396 last_corrterm: float 

1397 Corrterm in the last iteration for getting the change of slope with 

1398 change in corrterm 

1399 

1400 last_slope: float 

1401 Same as for `last_corrterm` 

1402 

1403 """ 

1404 def __init__(self, poissonsolver, direction, width=1.0, dirichlet=False): 

1405 """Construct dipole correction object.""" 

1406 

1407 DipoleCorrection.__init__(self, poissonsolver, direction, width=1.0) 

1408 self.corrterm = 1 

1409 self.elcorr = None 

1410 self.last_corrterm = None 

1411 self.dirichlet = dirichlet 

1412 

1413 def solve(self, pot, dens, **kwargs): 

1414 if isinstance(dens, np.ndarray): 

1415 # finite-diference Poisson solver: 

1416 if hasattr(self.poissonsolver, 'dielectric'): 

1417 return self.fd_solv_solve(pot, dens, **kwargs) 

1418 else: 

1419 return self.fdsolve(pot, dens, **kwargs) 

1420 # Plane-wave solver: 

1421 self.pwsolve(pot, dens) 

1422 

1423 def fd_solv_solve(self, vHt_g, rhot_g, **kwargs): 

1424 

1425 gd = self.poissonsolver.gd 

1426 slope_lim = 1e-8 

1427 slope = slope_lim * 10 

1428 

1429 dipmom = gd.calculate_dipole_moment(rhot_g)[2] 

1430 

1431 if self.elcorr is not None: 

1432 vHt_g[:, :] -= self.elcorr 

1433 

1434 iters2 = self.poissonsolver.solve(vHt_g, rhot_g, **kwargs) 

1435 sawtooth_z = self.sjm_sawtooth(dirichlet=self.dirichlet) 

1436 L = gd.cell_cv[2, 2] 

1437 

1438 while abs(slope) > slope_lim: 

1439 vHt_g2 = vHt_g.copy() 

1440 self.correction = 2 * np.pi * dipmom * L / \ 

1441 gd.volume * self.corrterm 

1442 elcorr = -2 * self.correction 

1443 

1444 elcorr *= sawtooth_z 

1445 elcorr2 = elcorr[gd.beg_c[2]:gd.end_c[2]] 

1446 vHt_g2[:, :] += elcorr2 

1447 

1448 VHt_g = gd.collect(vHt_g2, broadcast=True) 

1449 VHt_z = VHt_g.mean(0).mean(0) 

1450 slope = VHt_z[2] - VHt_z[10] 

1451 

1452 if abs(slope) > slope_lim: 

1453 if self.last_corrterm is not None: 

1454 ds = (slope - self.last_slope) / \ 

1455 (self.corrterm - self.last_corrterm) 

1456 con = slope - (ds * self.corrterm) 

1457 self.last_corrterm = self.corrterm 

1458 self.corrterm = -con / ds 

1459 else: 

1460 self.last_corrterm = self.corrterm 

1461 self.corrterm -= slope * 10. 

1462 self.last_slope = slope 

1463 else: 

1464 vHt_g[:, :] += elcorr2 

1465 self.elcorr = elcorr2 

1466 

1467 return iters2 

1468 

1469 def sjm_sawtooth(self, dirichlet): 

1470 """Creates a linear function normalized between -0.5 and 0.5 whose 

1471 slope is scaled based on the xy-averaged dielectric constant vs z""" 

1472 gd = self.poissonsolver.gd 

1473 c = self.c 

1474 L = gd.cell_cv[c, c] 

1475 step = gd.h_cv[c, c] / L 

1476 

1477 eps_g = gd.collect(self.poissonsolver.dielectric.eps_gradeps[0], 

1478 broadcast=True) 

1479 eps_z = eps_g.mean(0).mean(0) 

1480 

1481 saw = np.zeros((int(L / gd.h_cv[c, c]))) 

1482 for i, eps in enumerate(eps_z): 

1483 saw[i + 1] = saw[i] + step / eps 

1484 saw /= saw[-1] + step / eps_z[-1] - saw[0] 

1485 

1486 if dirichlet: 

1487 saw -= saw[-1] 

1488 else: 

1489 saw -= (saw[0] + saw[-1] + step / eps_z[-1]) / 2. 

1490 

1491 return saw 

1492 

1493 

1494class PotentialConvergenceError(ConvergenceError): 

1495 """Raised if potential did not equilibrate."""