Coverage for gpaw/test/solvation/test_pw_poisson.py: 100%

153 statements  

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

1import numpy as np 

2import pytest 

3import time 

4from ase.units import Bohr 

5from gpaw.core import PWDesc, UGDesc, PWArray 

6from gpaw.new.pw.poisson import ConjugateGradientPoissonSolver 

7from ase.parallel import parprint 

8 

9nn = 3 

10accuracy = 2e-10 

11BOX = 12.0 

12 

13 

14def generate_grid(box, h=0.4, pbc=[True, True, True], dtype=float): 

15 """ 

16 Create a grid for testing. 

17 

18 Parameters: 

19 ---------- 

20 box : float 

21 Box size in Ångström 

22 h : float 

23 Grid spacing in Ångström 

24 pbc : list 

25 Periodic boundary conditions 

26 dtype : type 

27 Data type for the grid (float or complex) 

28 

29 Returns: 

30 ------- 

31 grid : UGDesc 

32 Grid descriptor 

33 """ 

34 diag = np.array([box] * 3) 

35 cell = np.diag(diag) 

36 grid_shape = tuple((diag / h * 2).astype(int)) 

37 

38 return UGDesc(cell=cell / Bohr, size=grid_shape, pbc=pbc, dtype=dtype) 

39 

40 

41def generate_dipole_charges(grid, pw, box): 

42 """ 

43 Generate a dipole charge distribution (positive and negative charges). 

44 

45 Parameters: 

46 ----------- 

47 grid : UGDesc 

48 Grid descriptor 

49 pw : PWDesc 

50 Plane wave descriptor 

51 box : float 

52 Box size 

53 

54 Returns: 

55 --------- 

56 rho : PWArray 

57 Charge density for testing 

58 """ 

59 xyz = grid.xyz() 

60 x = xyz[:, :, :, 0] 

61 y = xyz[:, :, :, 1] 

62 z = xyz[:, :, :, 2] 

63 center = box / (2 * Bohr) 

64 

65 radius = 1.0 / Bohr 

66 pos_center = np.array([center - 2.0, center, center]) 

67 neg_center = np.array([center + 2.0, center, center]) 

68 

69 r_pos = np.sqrt( 

70 (x - pos_center[0])**2 + 

71 (y - pos_center[1])**2 + 

72 (z - pos_center[2])**2) 

73 r_neg = np.sqrt( 

74 (x - neg_center[0])**2 + 

75 (y - neg_center[1])**2 + 

76 (z - neg_center[2])**2) 

77 

78 shape = tuple(grid.size_c) 

79 charge_array = np.zeros(shape) 

80 

81 sigma = 0.5 / Bohr 

82 pos_charge = 0.5 * (1.0 - np.tanh((r_pos - radius) / sigma)) 

83 neg_charge = -0.5 * (1.0 - np.tanh((r_neg - radius) / sigma)) 

84 

85 charge_array = pos_charge + neg_charge 

86 

87 total_charge = np.sum(charge_array) * grid.dv 

88 charge_array -= total_charge / (grid.dv * np.prod(shape)) 

89 

90 rho_r = grid.zeros() 

91 rho_r.data[:] = charge_array 

92 

93 rho = rho_r.fft(pw=pw) 

94 

95 return rho 

96 

97 

98def generate_quadrupole_charges(grid, pw, box, seed=None): 

99 """ 

100 Generate a quadrupole-like charge distribution with four charges 

101 arranged in a tetrahedral pattern. 

102 

103 Parameters: 

104 ----------- 

105 grid : UGDesc 

106 Grid descriptor 

107 pw : PWDesc 

108 Plane wave descriptor 

109 box : float 

110 Box size 

111 seed : int, optional 

112 Seed for random number generator 

113 

114 Returns: 

115 --------- 

116 rho : PWArray 

117 Charge density for testing 

118 """ 

119 if seed is not None: 

120 np.random.seed(seed) 

121 

122 xyz = grid.xyz() 

123 x = xyz[:, :, :, 0] 

124 y = xyz[:, :, :, 1] 

125 z = xyz[:, :, :, 2] 

126 center = box / (2 * Bohr) 

127 

128 radius = box / (4 * Bohr) 

129 charge_radius = 1.0 / Bohr 

130 

131 vertices = np.array([ 

132 [1, 1, 1], 

133 [1, -1, -1], 

134 [-1, 1, -1], 

135 [-1, -1, 1] 

136 ]) / np.sqrt(3) 

137 

138 centers = vertices * radius 

139 centers += np.array([center, center, center]) 

140 

141 charges = [1.0, -1.0, 1.0, -1.0] 

142 

143 shape = tuple(grid.size_c) 

144 charge_array = np.zeros(shape) 

145 sigma = 0.5 / Bohr 

146 

147 for q, pos in zip(charges, centers): 

148 r = np.sqrt((x - pos[0])**2 + (y - pos[1])**2 + (z - pos[2])**2) 

149 charge = q * 0.5 * (1.0 - np.tanh((r - charge_radius) / sigma)) 

150 charge_array += charge 

151 

152 total_charge = np.sum(charge_array) * grid.dv 

153 charge_array -= total_charge / (grid.dv * np.prod(shape)) 

154 

155 rho_r = grid.zeros() 

156 rho_r.data[:] = charge_array 

157 

158 rho = rho_r.fft(pw=pw) 

159 

160 return rho 

161 

162 

163def spherical_dielectric_function(grid, box, epsinf=80.0, eps1=1.0, 

164 radius=None): 

165 """ 

166 Generate a spherical dielectric function with high dielectric inside 

167 a sphere and low dielectric outside, with a smooth transition at the 

168 boundary. 

169 

170 Parameters: 

171 ---------- 

172 grid : UGDesc 

173 Grid descriptor 

174 box : float 

175 Box size in Ångström 

176 epsinf : float 

177 Dielectric constant inside the sphere (typically water = 80) 

178 eps1 : float 

179 Dielectric constant outside the sphere (typically vacuum = 1) 

180 radius : float, optional 

181 Radius of the dielectric sphere in Ångström. Defaults to 1/3 

182 of box size. 

183 

184 Returns: 

185 -------- 

186 eps : UGArray 

187 Dielectric function for testing 

188 eps_gradeps : list 

189 List containing eps and its gradient as UGArray objects 

190 """ 

191 xyz = grid.xyz() 

192 x = xyz[:, :, :, 0] 

193 y = xyz[:, :, :, 1] 

194 z = xyz[:, :, :, 2] 

195 center = box / (2 * Bohr) 

196 

197 if radius is None: 

198 radius = box / 3.0 

199 radius_bohr = radius / Bohr 

200 

201 r = np.sqrt((x - center)**2 + (y - center)**2 + (z - center)**2) 

202 

203 width = 0.5 / Bohr 

204 transition = 0.5 * (1.0 - np.tanh((r - radius_bohr) / width)) 

205 

206 eps_np = eps1 + (epsinf - eps1) * transition 

207 

208 deps_dr_np = -(epsinf - eps1) * 0.5 * ( 

209 1.0 - np.tanh((r - radius_bohr) / width)**2) / width 

210 

211 grad_x_np = np.zeros_like(r) 

212 grad_y_np = np.zeros_like(r) 

213 grad_z_np = np.zeros_like(r) 

214 

215 mask = r > 1e-10 

216 grad_x_np[mask] = deps_dr_np[mask] * (x[mask] - center) / r[mask] 

217 grad_y_np[mask] = deps_dr_np[mask] * (y[mask] - center) / r[mask] 

218 grad_z_np[mask] = deps_dr_np[mask] * (z[mask] - center) / r[mask] 

219 

220 eps = grid.zeros() 

221 eps.data[:] = eps_np 

222 

223 grad_x = grid.zeros() 

224 grad_x.data[:] = grad_x_np 

225 

226 grad_y = grid.zeros() 

227 grad_y.data[:] = grad_y_np 

228 

229 grad_z = grid.zeros() 

230 grad_z.data[:] = grad_z_np 

231 

232 eps_gradeps = [eps, grad_x, grad_y, grad_z] 

233 

234 return eps, eps_gradeps 

235 

236 

237@pytest.mark.parametrize("density_generator", [ 

238 generate_dipole_charges, 

239 lambda grid, pw, box: generate_quadrupole_charges(grid, pw, box, seed=0), 

240 lambda grid, pw, box: generate_quadrupole_charges(grid, pw, box, seed=1), 

241]) 

242def test_cg_poisson_solver_constant_dielectric(density_generator): 

243 """Test the conjugate gradient Poisson solver with constant dielectric.""" 

244 box = BOX 

245 grid = generate_grid(box, dtype=complex) 

246 

247 pw = PWDesc(ecut=20.0, cell=grid.cell, kpt=grid.kpt, comm=grid.comm, 

248 dtype=complex) 

249 

250 epsinf = 80.0 

251 

252 shape = tuple(grid.size_c) 

253 eps_np = np.ones(shape) * epsinf 

254 

255 eps = grid.zeros() 

256 eps.data[:] = eps_np 

257 

258 grad_zeros_np = np.zeros(shape) 

259 

260 grad_x = grid.zeros() 

261 grad_x.data[:] = grad_zeros_np 

262 grad_y = grid.zeros() 

263 grad_y.data[:] = grad_zeros_np 

264 grad_z = grid.zeros() 

265 grad_z.data[:] = grad_zeros_np 

266 

267 eps_gradeps = [eps, grad_x, grad_y, grad_z] 

268 

269 rho = density_generator(grid, pw, box) 

270 

271 class MockDielectric: 

272 def __init__(self, eps_gradeps): 

273 self.eps_gradeps = [e.data for e in eps_gradeps] 

274 

275 solver = ConjugateGradientPoissonSolver( 

276 pw, grid, MockDielectric(eps_gradeps), 

277 eps=1e-6, maxiter=100) 

278 

279 phi = PWArray(pw=pw) 

280 start = time.time() 

281 solver.solve(phi, rho) 

282 end = time.time() 

283 parprint(f'CG Solver timing: {end - start}') 

284 

285 laplacian_phi = PWArray(pw=pw) 

286 G2_q = pw.ekin_G.copy() 

287 laplacian_phi.data[:] = G2_q * phi.data.copy() 

288 

289 expected_laplacian = PWArray(pw=pw) 

290 factor = 2 * np.pi / float(epsinf) 

291 expected_laplacian.data[:] = factor * rho.data.copy() 

292 

293 laplacian_phi_r = laplacian_phi.ifft(grid=grid).data 

294 expected_laplacian_r = expected_laplacian.ifft(grid=grid).data 

295 

296 assert laplacian_phi_r == pytest.approx(expected_laplacian_r, abs=1e-3) 

297 

298 

299@pytest.mark.parametrize("density_generator", [ 

300 generate_dipole_charges, 

301 lambda grid, pw, box: generate_quadrupole_charges(grid, pw, box, seed=0), 

302]) 

303def test_cg_poisson_solver_variable_dielectric(density_generator): 

304 """Test the conjugate gradient Poisson solver with variable dielectric.""" 

305 box = BOX 

306 grid = generate_grid(box, dtype=complex) 

307 

308 pw = PWDesc(ecut=20.0, cell=grid.cell, kpt=grid.kpt, comm=grid.comm, 

309 dtype=complex) 

310 

311 eps, eps_gradeps = spherical_dielectric_function(grid, box, 

312 epsinf=80.0, eps1=1.0) 

313 

314 rho = density_generator(grid, pw, box) 

315 

316 class MockDielectric: 

317 def __init__(self, eps_gradeps): 

318 self.eps_gradeps = [e.data for e in eps_gradeps] 

319 

320 solver = ConjugateGradientPoissonSolver( 

321 pw=pw, grid=grid, 

322 dielectric=MockDielectric(eps_gradeps), 

323 eps=1e-6, maxiter=100) 

324 

325 phi = PWArray(pw=pw) 

326 start = time.time() 

327 solver.solve(phi, rho) 

328 end = time.time() 

329 parprint(f'CG Solver timing: {end - start}') 

330 

331 phi_operator = solver.operator(phi.data.copy()) 

332 

333 expected = rho.data.copy() 

334 expected *= 4 * np.pi 

335 

336 assert np.allclose(phi_operator, expected, atol=1e-3)