Coverage for ase / io / nwchem / nwwriter.py: 78.80%

283 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-30 08:22 +0000

1# fmt: off 

2 

3import os 

4import random 

5import string 

6import warnings 

7from copy import deepcopy 

8 

9import numpy as np 

10 

11from ase.calculators.calculator import KPoints, kpts2kpts 

12 

13_special_kws = ['center', 'autosym', 'autoz', 'theory', 'basis', 'xc', 'task', 

14 'set', 'symmetry', 'label', 'geompar', 'basispar', 'kpts', 

15 'bandpath', 'restart_kw', 'pretasks', 'charge'] 

16 

17_system_type = {1: 'polymer', 2: 'surface', 3: 'crystal'} 

18 

19 

20def _render_geom(atoms, params: dict) -> list[str]: 

21 """Generate the geometry block 

22 

23 Parameters 

24 ---------- 

25 atoms : Atoms 

26 Geometry for the computation 

27 params : dict 

28 Parameter set for the computation 

29 

30 Returns 

31 ------- 

32 geom : [str] 

33 Geometry block to use in the computation 

34 """ 

35 geom_header = ['geometry units angstrom'] 

36 for geomkw in ['center', 'autosym', 'autoz']: 

37 geom_header.append(geomkw if params.get(geomkw) else 'no' + geomkw) 

38 if 'geompar' in params: 

39 geom_header.append(params['geompar']) 

40 geom = [' '.join(geom_header)] 

41 

42 outpos = atoms.get_positions() 

43 pbc = atoms.pbc 

44 if np.any(pbc): 

45 scpos = atoms.get_scaled_positions() 

46 for i, pbci in enumerate(pbc): 

47 if pbci: 

48 outpos[:, i] = scpos[:, i] 

49 npbc = pbc.sum() 

50 cellpars = atoms.cell.cellpar() 

51 geom.append(f' system {_system_type[npbc]} units angstrom') 

52 if npbc == 3: 

53 geom.append(' lattice_vectors') 

54 for row in atoms.cell: 

55 geom.append(' {:20.16e} {:20.16e} {:20.16e}'.format(*row)) 

56 else: 

57 if pbc[0]: 

58 geom.append(f' lat_a {cellpars[0]:20.16e}') 

59 if pbc[1]: 

60 geom.append(f' lat_b {cellpars[1]:20.16e}') 

61 if pbc[2]: 

62 geom.append(f' lat_c {cellpars[2]:20.16e}') 

63 if pbc[1] and pbc[2]: 

64 geom.append(f' alpha {cellpars[3]:20.16e}') 

65 if pbc[0] and pbc[2]: 

66 geom.append(f' beta {cellpars[4]:20.16e}') 

67 if pbc[1] and pbc[0]: 

68 geom.append(f' gamma {cellpars[5]:20.16e}') 

69 geom.append(' end') 

70 

71 for i, atom in enumerate(atoms): 

72 geom.append(' {:<2} {:20.16e} {:20.16e} {:20.16e}' 

73 ''.format(atom.symbol, *outpos[i])) 

74 symm = params.get('symmetry') 

75 if symm is not None: 

76 geom.append(f' symmetry {symm}') 

77 geom.append('end') 

78 return geom 

79 

80 

81def _render_basis(theory, params: dict) -> list[str]: 

82 """Infer the basis set block 

83 

84 Arguments 

85 --------- 

86 theory : str 

87 Name of the theory used for the calculation 

88 params : dict 

89 Parameters for the computation 

90 

91 Returns 

92 ------- 

93 [str] 

94 List of input file lines for the basis block 

95 """ 

96 

97 # Break if no basis set is provided and non is applicable 

98 if 'basis' not in params: 

99 if theory in ['pspw', 'band', 'paw']: 

100 return [] 

101 

102 # Write the header section 

103 if 'basispar' in params: 

104 header = 'basis {} noprint'.format(params['basispar']) 

105 else: 

106 header = 'basis noprint' 

107 basis_out = [header] 

108 

109 # Write the basis set for each atom type 

110 basis_in = params.get('basis', '3-21G') 

111 if isinstance(basis_in, str): 

112 basis_out.append(f' * library {basis_in}') 

113 else: 

114 for symbol, ibasis in basis_in.items(): 

115 basis_out.append(f'{symbol:>4} library {ibasis}') 

116 basis_out.append('end') 

117 return basis_out 

118 

119 

120_special_keypairs = [('nwpw', 'simulation_cell'), 

121 ('nwpw', 'carr-parinello'), 

122 ('nwpw', 'brillouin_zone'), 

123 ('tddft', 'grad'), 

124 ] 

125 

126 

127def _render_brillouin_zone(array, name=None) -> list[str]: 

128 out = [' brillouin_zone'] 

129 if name is not None: 

130 out += [f' zone_name {name}'] 

131 template = ' kvector' + ' {:20.16e}' * array.shape[1] 

132 for row in array: 

133 out.append(template.format(*row)) 

134 out.append(' end') 

135 return out 

136 

137 

138def _render_bandpath(bp) -> list[str]: 

139 if bp is None: 

140 return [] 

141 out = ['nwpw'] 

142 out += _render_brillouin_zone(bp.kpts, name=bp.path) 

143 out += [f' zone_structure_name {bp.path}', 

144 'end', 

145 'task band structure'] 

146 return out 

147 

148 

149def _format_line(key, val) -> str: 

150 if val is None: 

151 return key 

152 if isinstance(val, bool): 

153 return f'{key} .{str(val).lower()}.' 

154 else: 

155 return ' '.join([key, str(val)]) 

156 

157 

158def _format_block(key, val, nindent=0) -> list[str]: 

159 prefix = ' ' * nindent 

160 prefix2 = ' ' * (nindent + 1) 

161 if val is None: 

162 return [prefix + key] 

163 

164 if not isinstance(val, dict): 

165 return [prefix + _format_line(key, val)] 

166 

167 out = [prefix + key] 

168 for subkey, subval in val.items(): 

169 if (key, subkey) in _special_keypairs: 

170 if (key, subkey) == ('nwpw', 'brillouin_zone'): 

171 out += _render_brillouin_zone(subval) 

172 else: 

173 out += _format_block(subkey, subval, nindent + 1) 

174 else: 

175 if isinstance(subval, dict): 

176 subval = ' '.join([_format_line(a, b) 

177 for a, b in subval.items()]) 

178 out.append(prefix2 + ' '.join([_format_line(subkey, subval)])) 

179 out.append(prefix + 'end') 

180 return out 

181 

182 

183def _render_other(params) -> list[str]: 

184 """Render other commands 

185 

186 Parameters 

187 ---------- 

188 params : dict 

189 Parameter set to be rendered 

190 

191 Returns 

192 ------- 

193 out : [str] 

194 Block defining other commands 

195 """ 

196 out = [] 

197 for kw, block in params.items(): 

198 if kw in _special_kws: 

199 continue 

200 out += _format_block(kw, block) 

201 return out 

202 

203 

204def _render_set(set_params) -> list[str]: 

205 """Render the commands for the set parameters 

206 

207 Parameters 

208 ---------- 

209 set_params : dict 

210 Parameters being set 

211 

212 Returns 

213 ------- 

214 out : [str] 

215 Block defining set commands 

216 """ 

217 return ['set ' + _format_line(key, val) for key, val in set_params.items()] 

218 

219 

220_gto_theories = ['tce', 'ccsd', 'tddft', 'scf', 'dft', 

221 'direct_mp2', 'mp2', 'rimp2'] 

222_pw_theories = ['band', 'pspw', 'paw'] 

223_all_theories = _gto_theories + _pw_theories 

224 

225 

226def _get_theory(params: dict) -> str: 

227 """Infer the theory given the user-provided settings 

228 

229 Parameters 

230 ---------- 

231 params : dict 

232 Parameters for the computation 

233 

234 Returns 

235 ------- 

236 theory : str 

237 Theory directive to use 

238 """ 

239 # Default: user-provided theory 

240 theory = params.get('theory') 

241 if theory is not None: 

242 return theory 

243 

244 # Check if the user passed a theory to xc 

245 xc = params.get('xc') 

246 if xc in _all_theories: 

247 return xc 

248 

249 # Check for input blocks that correspond to a particular level of 

250 # theory. Correlated theories (e.g. CCSD) are checked first. 

251 for kw in _gto_theories: 

252 if kw in params: 

253 return kw 

254 

255 # If the user passed an 'nwpw' block, then they want a plane-wave 

256 # calculation, but what kind? If they request k-points, then 

257 # they want 'band', otherwise assume 'pspw' (if the user wants 

258 # to use 'paw', they will have to ask for it specifically). 

259 nwpw = params.get('nwpw') 

260 if nwpw is not None: 

261 if 'monkhorst-pack' in nwpw or 'brillouin_zone' in nwpw: 

262 return 'band' 

263 return 'pspw' 

264 

265 # When all else fails, default to dft. 

266 return 'dft' 

267 

268 

269_xc_conv = dict(lda='slater pw91lda', 

270 pbe='xpbe96 cpbe96', 

271 revpbe='revpbe cpbe96', 

272 rpbe='rpbe cpbe96', 

273 pw91='xperdew91 perdew91', 

274 ) 

275 

276 

277def _update_mult(magmom_tot: int, params: dict) -> None: 

278 """Update parameters for multiplicity given the magnetic moment 

279 

280 For example, sets the number of open shells for SCF calculations 

281 and the multiplicity for DFT calculations. 

282 

283 Parameters 

284 ---------- 

285 magmom_tot : int 

286 Total magnetic moment of the system 

287 params : dict 

288 Current set of parameters, will be modified 

289 """ 

290 # Determine theory and multiplicity 

291 theory = params['theory'] 

292 if magmom_tot == 0: 

293 magmom_mult = 1 

294 else: 

295 magmom_mult = np.sign(magmom_tot) * (abs(magmom_tot) + 1) 

296 

297 # Adjust the kwargs for each type of theory 

298 if 'scf' in params: 

299 for kw in ['nopen', 'singlet', 'doublet', 'triplet', 'quartet', 

300 'quintet', 'sextet', 'septet', 'octet']: 

301 if kw in params['scf']: 

302 break 

303 else: 

304 params['scf']['nopen'] = magmom_tot 

305 elif theory in ['scf', 'mp2', 'direct_mp2', 'rimp2', 'ccsd', 'tce']: 

306 params['scf'] = dict(nopen=magmom_tot) 

307 

308 if 'dft' in params: 

309 if 'mult' not in params['dft']: 

310 params['dft']['mult'] = magmom_mult 

311 elif theory in ['dft', 'tddft']: 

312 params['dft'] = dict(mult=magmom_mult) 

313 

314 if 'nwpw' in params: 

315 if 'mult' not in params['nwpw']: 

316 params['nwpw']['mult'] = magmom_mult 

317 elif theory in ['pspw', 'band', 'paw']: 

318 params['nwpw'] = dict(mult=magmom_mult) 

319 

320 

321def _update_kpts(atoms, params) -> None: 

322 """Converts top-level 'kpts' argument to native keywords 

323 

324 Parameters 

325 ---------- 

326 atoms : Atoms 

327 Input structure 

328 params : dict 

329 Current parameter set, will be updated 

330 """ 

331 kpts = params.get('kpts') 

332 if kpts is None: 

333 return 

334 

335 nwpw = params.get('nwpw', {}) 

336 

337 if 'monkhorst-pack' in nwpw or 'brillouin_zone' in nwpw: 

338 raise ValueError("Redundant k-points specified!") 

339 

340 if isinstance(kpts, KPoints): 

341 nwpw['brillouin_zone'] = kpts.kpts 

342 elif isinstance(kpts, dict): 

343 if kpts.get('gamma', False) or 'size' not in kpts: 

344 nwpw['brillouin_zone'] = kpts2kpts(kpts, atoms).kpts 

345 else: 

346 nwpw['monkhorst-pack'] = ' '.join(map(str, kpts['size'])) 

347 elif isinstance(kpts, np.ndarray): 

348 nwpw['brillouin_zone'] = kpts 

349 else: 

350 nwpw['monkhorst-pack'] = ' '.join(map(str, kpts)) 

351 

352 params['nwpw'] = nwpw 

353 

354 

355def _render_pretask( 

356 this_step: dict, 

357 previous_basis: list[str] | None, 

358 wfc_path: str, 

359 next_steps: list[dict], 

360) -> tuple[list[str], list[str]]: 

361 """Generate input file lines that perform a cheaper method first 

362 

363 Parameters 

364 ---------- 

365 this_step: dict 

366 Input parameters used to define the computation 

367 previous_basis: [str], optional 

368 Basis set block used in the previous step 

369 wfc_path: str 

370 Name of the wavefunction path 

371 next_steps: [dict] 

372 Parameters for the next steps in the calculation. 

373 This function will adjust the next steps to read 

374 and project from the wave functions written to disk by this step 

375 if the basis set changes between this step and the next. 

376 

377 Returns 

378 ------- 

379 output: [str] 

380 Output lines for this task 

381 this_basis: [str] 

382 Basis set block used for this task 

383 """ 

384 

385 # Get the theory for the next step 

386 next_step = next_steps[0] 

387 next_theory = _get_theory(next_step) 

388 next_step['theory'] = next_theory 

389 out = [] 

390 if next_theory not in ['dft', 'mp2', 'direct_mp2', 'rimp2', 'scf']: 

391 raise ValueError(f'Initial guesses not supported for {next_theory}') 

392 

393 # Determine the theory for this step 

394 this_theory = _get_theory(this_step) 

395 this_step['theory'] = this_theory 

396 if this_theory not in ['dft', 'scf']: 

397 raise ValueError('Initial guesses must use either dft or scf') 

398 

399 # Determine which basis set to use for this step. Our priorities 

400 # 1. Basis defined explicitly in this step 

401 # 2. Basis set of the previous step 

402 # 3. Basis set of the target computation level 

403 if 'basis' in this_step: 

404 this_basis = _render_basis(this_theory, this_step) 

405 elif previous_basis is not None: 

406 this_basis = previous_basis.copy() 

407 else: 

408 # Use the basis for the last step 

409 this_basis = _render_basis(next_theory, next_steps[-1]) 

410 

411 # Determine the basis for the next step 

412 # If not defined, it'll be the same as this step 

413 if 'basis' in next_step: 

414 next_basis = _render_basis(next_theory, next_step) 

415 else: 

416 next_basis = this_basis 

417 

418 # Set up projections if needed 

419 if this_basis == next_basis: 

420 out.append('\n'.join(this_basis)) 

421 proj_from = None # We do not need to project basis 

422 else: 

423 # Check for known limitations of NWChem 

424 if this_theory != next_theory: 

425 msg = 'Theories must be the same if basis are different. ' \ 

426 f'This step: {this_theory}//{this_basis} ' \ 

427 f'Next step: {next_theory}//{next_basis}' 

428 if 'basis' not in this_step: 

429 msg += f". Consider specifying basis in {this_step}" 

430 raise ValueError(msg) 

431 if not any('* library' in x for x in this_basis): 

432 raise ValueError('We can only support projecting from systems ' 

433 'where all atoms share the same basis') 

434 

435 # Append a new name to this basis function by 

436 # appending it as the first argument of the basis block 

437 proj_from = f"smb_{len(next_steps)}" 

438 this_basis[0] = f'basis {proj_from} {this_basis[0][6:]}' 

439 out.append('\n'.join(this_basis)) 

440 

441 # Point ao basis (the active basis set) to this new basis set 

442 out.append(f'set "ao basis" {proj_from}') 

443 

444 # Insert a command to write wfcs from this computation to disk 

445 if this_theory not in this_step: 

446 this_step[this_theory] = {} 

447 if 'vectors' not in this_step[this_theory]: 

448 this_step[this_theory]['vectors'] = {} 

449 this_step[this_theory]['vectors']['output'] = wfc_path 

450 

451 # Check if the initial theory changes 

452 if this_theory != next_theory and \ 

453 'lindep:n_dep' not in this_step.get('set', {}): 

454 warnings.warn('Loading initial guess may fail if you do not specify' 

455 ' the number of linearly-dependent basis functions.' 

456 ' Consider adding {"set": {"lindep:n_dep": 0}} ' 

457 f' to the step: {this_step}.') 

458 

459 # Add this to the input file along with a "task * ignore" command 

460 out.extend(['\n'.join(_render_other(this_step)), 

461 '\n'.join(_render_set(this_step.get('set', {}))), 

462 f'task {this_theory} ignore']) 

463 

464 # Command to read the wavefunctions in the next step 

465 # Theory used to get the wavefunctions may be different (mp2 uses SCF) 

466 wfc_theory = 'scf' if 'mp2' in next_theory else next_theory 

467 next_step = next_step 

468 if wfc_theory not in next_step: 

469 next_step[wfc_theory] = {} 

470 if 'vectors' not in next_step[wfc_theory]: 

471 next_step[wfc_theory]['vectors'] = {} 

472 

473 if proj_from is None: 

474 # No need for projection 

475 next_step[wfc_theory]['vectors']['input'] = wfc_path 

476 else: 

477 # Define that we should project from our basis set 

478 next_step[wfc_theory]['vectors']['input'] \ 

479 = f'project {proj_from} {wfc_path}' 

480 

481 # Replace the name of the basis set to the default 

482 out.append('set "ao basis" "ao basis"') 

483 

484 return out, this_basis 

485 

486 

487def write_nwchem_in(fd, atoms, properties=None, echo=False, **params): 

488 """Writes NWChem input file. 

489 

490 See :class:`~ase.calculators.nwchem.NWChem` for available params. 

491 

492 Parameters 

493 ---------- 

494 fd 

495 file descriptor 

496 atoms 

497 atomic configuration 

498 properties 

499 list of properties to compute; by default only the 

500 calculation of the energy is requested 

501 echo 

502 if True include the `echo` keyword at the top of the file, 

503 which causes the content of the input file to be included 

504 in the output file 

505 params 

506 dict of instructions blocks to be included 

507 """ 

508 # Copy so we can alter params w/o changing it for the function caller 

509 params = deepcopy(params) 

510 

511 if properties is None: 

512 properties = ['energy'] 

513 

514 if 'stress' in properties: 

515 if 'set' not in params: 

516 params['set'] = {} 

517 params['set']['includestress'] = True 

518 

519 task = params.get('task') 

520 if task is None: 

521 if 'stress' in properties or 'forces' in properties: 

522 task = 'gradient' 

523 else: 

524 task = 'energy' 

525 

526 _update_kpts(atoms, params) 

527 

528 # Determine the theory for each step 

529 # We determine the theory ahead of time because it is 

530 # used when generating other parts of the input file (e.g., _get_mult) 

531 theory = _get_theory(params) 

532 params['theory'] = theory 

533 

534 for pretask in params.get('pretasks', []): 

535 pretask['theory'] = _get_theory(pretask) 

536 

537 if 'xc' in params: 

538 xc = _xc_conv.get(params['xc'].lower(), params['xc']) 

539 if theory in ['dft', 'tddft']: 

540 if 'dft' not in params: 

541 params['dft'] = {} 

542 params['dft']['xc'] = xc 

543 elif theory in ['pspw', 'band', 'paw']: 

544 if 'nwpw' not in params: 

545 params['nwpw'] = {} 

546 params['nwpw']['xc'] = xc 

547 

548 # Update the multiplicity given the charge of the system 

549 magmom_tot = int(atoms.get_initial_magnetic_moments().sum()) 

550 _update_mult(magmom_tot, params) 

551 for pretask in params.get('pretasks', []): 

552 _update_mult(magmom_tot, pretask) 

553 

554 # Determine the header options 

555 label = params.get('label', 'nwchem') 

556 perm = os.path.abspath(params.pop('perm', label)) 

557 scratch = os.path.abspath(params.pop('scratch', label)) 

558 restart_kw = params.get('restart_kw', 'start') 

559 if restart_kw not in ('start', 'restart'): 

560 raise ValueError("Unrecognised restart keyword: {}!" 

561 .format(restart_kw)) 

562 short_label = label.rsplit('/', 1)[-1] 

563 if echo: 

564 out = ['echo'] 

565 else: 

566 out = [] 

567 

568 # Defines the geometry and global options 

569 out.extend([f'title "{short_label}"', 

570 f'permanent_dir {perm}', 

571 f'scratch_dir {scratch}', 

572 f'{restart_kw} {short_label}', 

573 '\n'.join(_render_set(params.get('set', {}))), 

574 '\n'.join(_render_geom(atoms, params))]) 

575 

576 # Add the charge if provided 

577 if 'charge' in params: 

578 out.append(f'charge {params["charge"]}') 

579 

580 # Define the memory separately from the other options so that it 

581 # is defined before any initial wfc guesses are performed 

582 memory_opts = params.pop('memory', None) 

583 if memory_opts is not None: 

584 out.extend(_render_other(dict(memory=memory_opts))) 

585 

586 # If desired, output commands to generate the initial wavefunctions 

587 if 'pretasks' in params: 

588 wfc_path = f'tmp-{"".join(random.choices(string.hexdigits, k=8))}.wfc' 

589 pretasks = params['pretasks'] 

590 previous_basis = None 

591 for this_ind, this_step in enumerate(pretasks): 

592 new_out, previous_basis = _render_pretask( 

593 this_step, 

594 previous_basis, 

595 wfc_path, 

596 pretasks[this_ind + 1:] + [params] 

597 ) 

598 out.extend(new_out) 

599 

600 # Finish output file with the commands to perform the desired computation 

601 out.extend(['\n'.join(_render_basis(theory, params)), 

602 '\n'.join(_render_other(params)), 

603 f'task {theory} {task}', 

604 '\n'.join(_render_bandpath(params.get('bandpath', None)))]) 

605 

606 fd.write('\n\n'.join(out))