Coverage for /builds/ase/ase/ase/calculators/calculator.py: 83.92%

541 statements  

« prev     ^ index     » next       coverage.py v7.5.3, created at 2025-08-02 00:12 +0000

1# fmt: off 

2 

3import copy 

4import os 

5import shlex 

6import subprocess 

7import warnings 

8from abc import abstractmethod 

9from dataclasses import dataclass, field 

10from math import pi, sqrt 

11from pathlib import Path 

12from typing import Any, Dict, List, Optional, Sequence, Set, Union 

13 

14import numpy as np 

15 

16from ase.calculators.abc import GetPropertiesMixin 

17from ase.cell import Cell 

18from ase.config import cfg as _cfg 

19from ase.outputs import Properties, all_outputs 

20from ase.utils import deprecated, jsonable 

21 

22from .names import names 

23 

24 

25class CalculatorError(RuntimeError): 

26 """Base class of error types related to ASE calculators.""" 

27 

28 

29class CalculatorSetupError(CalculatorError): 

30 """Calculation cannot be performed with the given parameters. 

31 

32 Reasons to raise this error are: 

33 * The calculator is not properly configured 

34 (missing executable, environment variables, ...) 

35 * The given atoms object is not supported 

36 * Calculator parameters are unsupported 

37 

38 Typically raised before a calculation.""" 

39 

40 

41class EnvironmentError(CalculatorSetupError): 

42 """Raised if calculator is not properly set up with ASE. 

43 

44 May be missing an executable or environment variables.""" 

45 

46 

47class InputError(CalculatorSetupError): 

48 """Raised if inputs given to the calculator were incorrect. 

49 

50 Bad input keywords or values, or missing pseudopotentials. 

51 

52 This may be raised before or during calculation, depending on 

53 when the problem is detected.""" 

54 

55 

56class CalculationFailed(CalculatorError): 

57 """Calculation failed unexpectedly. 

58 

59 Reasons to raise this error are: 

60 * Calculation did not converge 

61 * Calculation ran out of memory 

62 * Segmentation fault or other abnormal termination 

63 * Arithmetic trouble (singular matrices, NaN, ...) 

64 

65 Typically raised during calculation.""" 

66 

67 

68class SCFError(CalculationFailed): 

69 """SCF loop did not converge.""" 

70 

71 

72class ReadError(CalculatorError): 

73 """Unexpected irrecoverable error while reading calculation results.""" 

74 

75 

76class PropertyNotImplementedError(NotImplementedError): 

77 """Raised if a calculator does not implement the requested property.""" 

78 

79 

80class PropertyNotPresent(CalculatorError): 

81 """Requested property is missing. 

82 

83 Maybe it was never calculated, or for some reason was not extracted 

84 with the rest of the results, without being a fatal ReadError.""" 

85 

86 

87def compare_atoms(atoms1, atoms2, tol=1e-15, excluded_properties=None): 

88 """Check for system changes since last calculation. Properties in 

89 ``excluded_properties`` are not checked.""" 

90 if atoms1 is None: 

91 system_changes = all_changes[:] 

92 else: 

93 system_changes = [] 

94 

95 properties_to_check = set(all_changes) 

96 if excluded_properties: 

97 properties_to_check -= set(excluded_properties) 

98 

99 # Check properties that aren't in Atoms.arrays but are attributes of 

100 # Atoms objects 

101 for prop in ['cell', 'pbc']: 

102 if prop in properties_to_check: 

103 properties_to_check.remove(prop) 

104 if not equal( 

105 getattr(atoms1, prop), getattr(atoms2, prop), atol=tol 

106 ): 

107 system_changes.append(prop) 

108 

109 arrays1 = set(atoms1.arrays) 

110 arrays2 = set(atoms2.arrays) 

111 

112 # Add any properties that are only in atoms1.arrays or only in 

113 # atoms2.arrays (and aren't excluded). Note that if, e.g. arrays1 has 

114 # `initial_charges` which is merely zeros and arrays2 does not have 

115 # this array, we'll still assume that the system has changed. However, 

116 # this should only occur rarely. 

117 system_changes += properties_to_check & (arrays1 ^ arrays2) 

118 

119 # Finally, check all of the non-excluded properties shared by the atoms 

120 # arrays. 

121 for prop in properties_to_check & arrays1 & arrays2: 

122 if not equal(atoms1.arrays[prop], atoms2.arrays[prop], atol=tol): 

123 system_changes.append(prop) 

124 

125 return system_changes 

126 

127 

128all_properties = [ 

129 'energy', 

130 'forces', 

131 'stress', 

132 'stresses', 

133 'dipole', 

134 'charges', 

135 'magmom', 

136 'magmoms', 

137 'free_energy', 

138 'energies', 

139 'dielectric_tensor', 

140 'born_effective_charges', 

141 'polarization', 

142] 

143 

144 

145all_changes = [ 

146 'positions', 

147 'numbers', 

148 'cell', 

149 'pbc', 

150 'initial_charges', 

151 'initial_magmoms', 

152] 

153 

154 

155special = { 

156 'cp2k': 'CP2K', 

157 'demonnano': 'DemonNano', 

158 'dftd3': 'DFTD3', 

159 'dmol': 'DMol3', 

160 'eam': 'EAM', 

161 'elk': 'ELK', 

162 'emt': 'EMT', 

163 'exciting': 'ExcitingGroundStateCalculator', 

164 'crystal': 'CRYSTAL', 

165 'ff': 'ForceField', 

166 'gamess_us': 'GAMESSUS', 

167 'gulp': 'GULP', 

168 'kim': 'KIM', 

169 'lammpsrun': 'LAMMPS', 

170 'lammpslib': 'LAMMPSlib', 

171 'lj': 'LennardJones', 

172 'mopac': 'MOPAC', 

173 'morse': 'MorsePotential', 

174 'nwchem': 'NWChem', 

175 'openmx': 'OpenMX', 

176 'orca': 'ORCA', 

177 'qchem': 'QChem', 

178 'tip3p': 'TIP3P', 

179 'tip4p': 'TIP4P', 

180} 

181 

182 

183external_calculators = {} 

184 

185 

186def register_calculator_class(name, cls): 

187 """Add the class into the database.""" 

188 assert name not in external_calculators 

189 external_calculators[name] = cls 

190 names.append(name) 

191 names.sort() 

192 

193 

194def get_calculator_class(name): 

195 """Return calculator class.""" 

196 if name == 'asap': 

197 from asap3 import EMT as Calculator 

198 elif name == 'gpaw': 

199 from gpaw import GPAW as Calculator 

200 elif name == 'hotbit': 

201 from hotbit import Calculator 

202 elif name == 'vasp2': 

203 from ase.calculators.vasp import Vasp2 as Calculator 

204 elif name == 'ace': 

205 from ase.calculators.acemolecule import ACE as Calculator 

206 elif name == 'Psi4': 

207 from ase.calculators.psi4 import Psi4 as Calculator 

208 elif name == 'mattersim': 

209 from mattersim.forcefield import MatterSimCalculator as Calculator 

210 elif name == 'mace_mp': 

211 from mace.calculators import mace_mp as Calculator 

212 elif name in external_calculators: 

213 Calculator = external_calculators[name] 

214 else: 

215 classname = special.get(name, name.title()) 

216 module = __import__('ase.calculators.' + name, {}, None, [classname]) 

217 Calculator = getattr(module, classname) 

218 return Calculator 

219 

220 

221def equal(a, b, tol=None, rtol=None, atol=None): 

222 """ndarray-enabled comparison function.""" 

223 # XXX Known bugs: 

224 # * Comparing cell objects (pbc not part of array representation) 

225 # * Infinite recursion for cyclic dicts 

226 # * Can of worms is open 

227 if tol is not None: 

228 msg = 'Use `equal(a, b, rtol=..., atol=...)` instead of `tol=...`' 

229 warnings.warn(msg, DeprecationWarning) 

230 assert ( 

231 rtol is None and atol is None 

232 ), 'Do not use deprecated `tol` with `atol` and/or `rtol`' 

233 rtol = tol 

234 atol = tol 

235 

236 a_is_dict = isinstance(a, dict) 

237 b_is_dict = isinstance(b, dict) 

238 if a_is_dict or b_is_dict: 

239 # Check that both a and b are dicts 

240 if not (a_is_dict and b_is_dict): 

241 return False 

242 if a.keys() != b.keys(): 

243 return False 

244 return all(equal(a[key], b[key], rtol=rtol, atol=atol) for key in a) 

245 

246 if np.shape(a) != np.shape(b): 

247 return False 

248 

249 if rtol is None and atol is None: 

250 return np.array_equal(a, b) 

251 

252 if rtol is None: 

253 rtol = 0 

254 if atol is None: 

255 atol = 0 

256 

257 return np.allclose(a, b, rtol=rtol, atol=atol) 

258 

259 

260def kptdensity2monkhorstpack(atoms, kptdensity=3.5, even=True): 

261 """Convert k-point density to Monkhorst-Pack grid size. 

262 

263 atoms: Atoms object 

264 Contains unit cell and information about boundary conditions. 

265 kptdensity: float 

266 Required k-point density. Default value is 3.5 point per Ang^-1. 

267 even: bool 

268 Round up to even numbers. 

269 """ 

270 

271 recipcell = atoms.cell.reciprocal() 

272 kpts = [] 

273 for i in range(3): 

274 if atoms.pbc[i]: 

275 k = 2 * pi * sqrt((recipcell[i] ** 2).sum()) * kptdensity 

276 if even: 

277 kpts.append(2 * int(np.ceil(k / 2))) 

278 else: 

279 kpts.append(int(np.ceil(k))) 

280 else: 

281 kpts.append(1) 

282 return np.array(kpts) 

283 

284 

285def kpts2mp(atoms, kpts, even=False): 

286 if kpts is None: 

287 return np.array([1, 1, 1]) 

288 if isinstance(kpts, (float, int)): 

289 return kptdensity2monkhorstpack(atoms, kpts, even) 

290 else: 

291 return kpts 

292 

293 

294def kpts2sizeandoffsets( 

295 size=None, density=None, gamma=None, even=None, atoms=None 

296): 

297 """Helper function for selecting k-points. 

298 

299 Use either size or density. 

300 

301 size: 3 ints 

302 Number of k-points. 

303 density: float 

304 K-point density in units of k-points per Ang^-1. 

305 gamma: None or bool 

306 Should the Gamma-point be included? Yes / no / don't care: 

307 True / False / None. 

308 even: None or bool 

309 Should the number of k-points be even? Yes / no / don't care: 

310 True / False / None. 

311 atoms: Atoms object 

312 Needed for calculating k-point density. 

313 

314 """ 

315 

316 if size is not None and density is not None: 

317 raise ValueError( 

318 'Cannot specify k-point mesh size and ' 'density simultaneously' 

319 ) 

320 elif density is not None and atoms is None: 

321 raise ValueError( 

322 'Cannot set k-points from "density" unless ' 

323 'Atoms are provided (need BZ dimensions).' 

324 ) 

325 

326 if size is None: 

327 if density is None: 

328 size = [1, 1, 1] 

329 else: 

330 size = kptdensity2monkhorstpack(atoms, density, None) 

331 

332 # Not using the rounding from kptdensity2monkhorstpack as it doesn't do 

333 # rounding to odd numbers 

334 if even is not None: 

335 size = np.array(size) 

336 remainder = size % 2 

337 if even: 

338 size += remainder 

339 else: # Round up to odd numbers 

340 size += 1 - remainder 

341 

342 offsets = [0, 0, 0] 

343 if atoms is None: 

344 pbc = [True, True, True] 

345 else: 

346 pbc = atoms.pbc 

347 

348 if gamma is not None: 

349 for i, s in enumerate(size): 

350 if pbc[i] and s % 2 != bool(gamma): 

351 offsets[i] = 0.5 / s 

352 

353 return size, offsets 

354 

355 

356@jsonable('kpoints') 

357class KPoints: 

358 def __init__(self, kpts=None): 

359 if kpts is None: 

360 kpts = np.zeros((1, 3)) 

361 self.kpts = kpts 

362 

363 def todict(self): 

364 return vars(self) 

365 

366 

367def kpts2kpts(kpts, atoms=None): 

368 from ase.dft.kpoints import monkhorst_pack 

369 

370 if kpts is None: 

371 return KPoints() 

372 

373 if hasattr(kpts, 'kpts'): 

374 return kpts 

375 

376 if isinstance(kpts, dict): 

377 if 'kpts' in kpts: 

378 return KPoints(kpts['kpts']) 

379 if 'path' in kpts: 

380 cell = Cell.ascell(atoms.cell) 

381 return cell.bandpath(pbc=atoms.pbc, **kpts) 

382 size, offsets = kpts2sizeandoffsets(atoms=atoms, **kpts) 

383 return KPoints(monkhorst_pack(size) + offsets) 

384 

385 if isinstance(kpts[0], int): 

386 return KPoints(monkhorst_pack(kpts)) 

387 

388 return KPoints(np.array(kpts)) 

389 

390 

391def kpts2ndarray(kpts, atoms=None): 

392 """Convert kpts keyword to 2-d ndarray of scaled k-points.""" 

393 return kpts2kpts(kpts, atoms=atoms).kpts 

394 

395 

396class Parameters(dict): 

397 """Dictionary for parameters. 

398 

399 Special feature: If param is a Parameters instance, then param.xc 

400 is a shorthand for param['xc']. 

401 """ 

402 

403 def __getattr__(self, key): 

404 if key not in self: 

405 return dict.__getattribute__(self, key) 

406 return self[key] 

407 

408 def __setattr__(self, key, value): 

409 self[key] = value 

410 

411 @classmethod 

412 def read(cls, filename): 

413 """Read parameters from file.""" 

414 # We use ast to evaluate literals, avoiding eval() 

415 # for security reasons. 

416 import ast 

417 

418 with open(filename) as fd: 

419 txt = fd.read().strip() 

420 assert txt.startswith('dict(') 

421 assert txt.endswith(')') 

422 txt = txt[5:-1] 

423 

424 # The tostring() representation "dict(...)" is not actually 

425 # a literal, so we manually parse that along with the other 

426 # formatting that we did manually: 

427 dct = {} 

428 for line in txt.splitlines(): 

429 key, val = line.split('=', 1) 

430 key = key.strip() 

431 val = val.strip() 

432 if val[-1] == ',': 

433 val = val[:-1] 

434 dct[key] = ast.literal_eval(val) 

435 

436 parameters = cls(dct) 

437 return parameters 

438 

439 def tostring(self): 

440 keys = sorted(self) 

441 return ( 

442 'dict(' 

443 + ',\n '.join(f'{key}={self[key]!r}' for key in keys) 

444 + ')\n' 

445 ) 

446 

447 def write(self, filename): 

448 Path(filename).write_text(self.tostring()) 

449 

450 

451class BaseCalculator(GetPropertiesMixin): 

452 implemented_properties: List[str] = [] 

453 'Properties calculator can handle (energy, forces, ...)' 

454 

455 # Placeholder object for deprecated arguments. Let deprecated keywords 

456 # default to _deprecated and then issue a warning if the user passed 

457 # any other object (such as None). 

458 _deprecated = object() 

459 

460 def __init__(self, parameters=None, use_cache=True): 

461 if parameters is None: 

462 parameters = {} 

463 

464 self.parameters = dict(parameters) 

465 self.atoms = None 

466 self.results = {} 

467 self.use_cache = use_cache 

468 

469 def calculate_properties(self, atoms, properties): 

470 """This method is experimental; currently for internal use.""" 

471 for name in properties: 

472 if name not in all_outputs: 

473 raise ValueError(f'No such property: {name}') 

474 

475 # We ignore system changes for now. 

476 self.calculate(atoms, properties, system_changes=all_changes) 

477 

478 props = self.export_properties() 

479 

480 for name in properties: 

481 if name not in props: 

482 raise PropertyNotPresent(name) 

483 return props 

484 

485 @abstractmethod 

486 def calculate(self, atoms, properties, system_changes): 

487 ... 

488 

489 def check_state(self, atoms, tol=1e-15): 

490 """Check for any system changes since last calculation.""" 

491 if self.use_cache: 

492 return compare_atoms(self.atoms, atoms, tol=tol) 

493 else: 

494 return all_changes 

495 

496 def get_property(self, name, atoms=None, allow_calculation=True): 

497 if name not in self.implemented_properties: 

498 raise PropertyNotImplementedError( 

499 f'{name} property not implemented' 

500 ) 

501 

502 if atoms is None: 

503 atoms = self.atoms 

504 system_changes = [] 

505 else: 

506 system_changes = self.check_state(atoms) 

507 

508 if system_changes: 

509 self.atoms = None 

510 self.results = {} 

511 

512 if name not in self.results: 

513 if not allow_calculation: 

514 return None 

515 

516 if self.use_cache: 

517 self.atoms = atoms.copy() 

518 

519 self.calculate(atoms, [name], system_changes) 

520 

521 if name not in self.results: 

522 # For some reason the calculator was not able to do what we want, 

523 # and that is OK. 

524 raise PropertyNotImplementedError( 

525 '{} not present in this ' 'calculation'.format(name) 

526 ) 

527 

528 result = self.results[name] 

529 if isinstance(result, np.ndarray): 

530 result = result.copy() 

531 return result 

532 

533 def calculation_required(self, atoms, properties): 

534 assert not isinstance(properties, str) 

535 system_changes = self.check_state(atoms) 

536 if system_changes: 

537 return True 

538 for name in properties: 

539 if name not in self.results: 

540 return True 

541 return False 

542 

543 def export_properties(self): 

544 return Properties(self.results) 

545 

546 def _get_name(self) -> str: # child class can override this 

547 return self.__class__.__name__.lower() 

548 

549 @property 

550 def name(self) -> str: 

551 return self._get_name() 

552 

553 def todict(self) -> dict[str, Any]: 

554 """Obtain a dictionary of parameter information""" 

555 return {} 

556 

557 

558class Calculator(BaseCalculator): 

559 """Base-class for all ASE calculators. 

560 

561 A calculator must raise PropertyNotImplementedError if asked for a 

562 property that it can't calculate. So, if calculation of the 

563 stress tensor has not been implemented, get_stress(atoms) should 

564 raise PropertyNotImplementedError. This can be achieved simply by not 

565 including the string 'stress' in the list implemented_properties 

566 which is a class member. These are the names of the standard 

567 properties: 'energy', 'forces', 'stress', 'dipole', 'charges', 

568 'magmom' and 'magmoms'. 

569 """ 

570 

571 default_parameters: Dict[str, Any] = {} 

572 'Default parameters' 

573 

574 ignored_changes: Set[str] = set() 

575 'Properties of Atoms which we ignore for the purposes of cache ' 

576 'invalidation with check_state().' 

577 

578 discard_results_on_any_change = False 

579 'Whether we purge the results following any change in the set() method. ' 

580 'Most (file I/O) calculators will probably want this.' 

581 

582 def __init__( 

583 self, 

584 restart=None, 

585 ignore_bad_restart_file=BaseCalculator._deprecated, 

586 label=None, 

587 atoms=None, 

588 directory='.', 

589 **kwargs, 

590 ): 

591 """Basic calculator implementation. 

592 

593 restart: str 

594 Prefix for restart file. May contain a directory. Default 

595 is None: don't restart. 

596 ignore_bad_restart_file: bool 

597 Deprecated, please do not use. 

598 Passing more than one positional argument to Calculator() 

599 is deprecated and will stop working in the future. 

600 Ignore broken or missing restart file. By default, it is an 

601 error if the restart file is missing or broken. 

602 directory: str or PurePath 

603 Working directory in which to read and write files and 

604 perform calculations. 

605 label: str 

606 Name used for all files. Not supported by all calculators. 

607 May contain a directory, but please use the directory parameter 

608 for that instead. 

609 atoms: Atoms object 

610 Optional Atoms object to which the calculator will be 

611 attached. When restarting, atoms will get its positions and 

612 unit-cell updated from file. 

613 """ 

614 self.atoms = None # copy of atoms object from last calculation 

615 self.results = {} # calculated properties (energy, forces, ...) 

616 self.parameters = None # calculational parameters 

617 self._directory = None # Initialize 

618 

619 if ignore_bad_restart_file is self._deprecated: 

620 ignore_bad_restart_file = False 

621 else: 

622 warnings.warn( 

623 FutureWarning( 

624 'The keyword "ignore_bad_restart_file" is deprecated and ' 

625 'will be removed in a future version of ASE. Passing more ' 

626 'than one positional argument to Calculator is also ' 

627 'deprecated and will stop functioning in the future. ' 

628 'Please pass arguments by keyword (key=value) except ' 

629 'optionally the "restart" keyword.' 

630 ) 

631 ) 

632 

633 if restart is not None: 

634 try: 

635 self.read(restart) # read parameters, atoms and results 

636 except ReadError: 

637 if ignore_bad_restart_file: 

638 self.reset() 

639 else: 

640 raise 

641 

642 self.directory = directory 

643 self.prefix = None 

644 if label is not None: 

645 if self.directory == '.' and '/' in label: 

646 # We specified directory in label, and nothing in the diretory 

647 # key 

648 self.label = label 

649 elif '/' not in label: 

650 # We specified our directory in the directory keyword 

651 # or not at all 

652 self.label = '/'.join((self.directory, label)) 

653 else: 

654 raise ValueError( 

655 'Directory redundantly specified though ' 

656 'directory="{}" and label="{}". ' 

657 'Please omit "/" in label.'.format(self.directory, label) 

658 ) 

659 

660 if self.parameters is None: 

661 # Use default parameters if they were not read from file: 

662 self.parameters = self.get_default_parameters() 

663 

664 if atoms is not None: 

665 atoms.calc = self 

666 if self.atoms is not None: 

667 # Atoms were read from file. Update atoms: 

668 if not ( 

669 equal(atoms.numbers, self.atoms.numbers) 

670 and (atoms.pbc == self.atoms.pbc).all() 

671 ): 

672 raise CalculatorError('Atoms not compatible with file') 

673 atoms.positions = self.atoms.positions 

674 atoms.cell = self.atoms.cell 

675 

676 self.set(**kwargs) 

677 

678 if not hasattr(self, 'get_spin_polarized'): 

679 self.get_spin_polarized = self._deprecated_get_spin_polarized 

680 # XXX We are very naughty and do not call super constructor! 

681 

682 # For historical reasons we have a particular caching protocol. 

683 # We disable the superclass' optional cache. 

684 self.use_cache = False 

685 

686 @property 

687 def directory(self) -> str: 

688 return self._directory 

689 

690 @directory.setter 

691 def directory(self, directory: Union[str, os.PathLike]): 

692 self._directory = str(Path(directory)) # Normalize path. 

693 

694 @property 

695 def label(self): 

696 if self.directory == '.': 

697 return self.prefix 

698 

699 # Generally, label ~ directory/prefix 

700 # 

701 # We use '/' rather than os.pathsep because 

702 # 1) directory/prefix does not represent any actual path 

703 # 2) We want the same string to work the same on all platforms 

704 if self.prefix is None: 

705 return self.directory + '/' 

706 

707 return f'{self.directory}/{self.prefix}' 

708 

709 @label.setter 

710 def label(self, label): 

711 if label is None: 

712 self.directory = '.' 

713 self.prefix = None 

714 return 

715 

716 tokens = label.rsplit('/', 1) 

717 if len(tokens) == 2: 

718 directory, prefix = tokens 

719 else: 

720 assert len(tokens) == 1 

721 directory = '.' 

722 prefix = tokens[0] 

723 if prefix == '': 

724 prefix = None 

725 self.directory = directory 

726 self.prefix = prefix 

727 

728 def set_label(self, label): 

729 """Set label and convert label to directory and prefix. 

730 

731 Examples: 

732 

733 * label='abc': (directory='.', prefix='abc') 

734 * label='dir1/abc': (directory='dir1', prefix='abc') 

735 * label=None: (directory='.', prefix=None) 

736 """ 

737 self.label = label 

738 

739 def get_default_parameters(self): 

740 return Parameters(copy.deepcopy(self.default_parameters)) 

741 

742 def todict(self, skip_default=True): 

743 defaults = self.get_default_parameters() 

744 dct = {} 

745 for key, value in self.parameters.items(): 

746 if hasattr(value, 'todict'): 

747 value = value.todict() 

748 if skip_default: 

749 default = defaults.get(key, '_no_default_') 

750 if default != '_no_default_' and equal(value, default): 

751 continue 

752 dct[key] = value 

753 return dct 

754 

755 def reset(self): 

756 """Clear all information from old calculation.""" 

757 

758 self.atoms = None 

759 self.results = {} 

760 

761 def read(self, label): 

762 """Read atoms, parameters and calculated properties from output file. 

763 

764 Read result from self.label file. Raise ReadError if the file 

765 is not there. If the file is corrupted or contains an error 

766 message from the calculation, a ReadError should also be 

767 raised. In case of succes, these attributes must set: 

768 

769 atoms: Atoms object 

770 The state of the atoms from last calculation. 

771 parameters: Parameters object 

772 The parameter dictionary. 

773 results: dict 

774 Calculated properties like energy and forces. 

775 

776 The FileIOCalculator.read() method will typically read atoms 

777 and parameters and get the results dict by calling the 

778 read_results() method.""" 

779 

780 self.set_label(label) 

781 

782 def get_atoms(self): 

783 if self.atoms is None: 

784 raise ValueError('Calculator has no atoms') 

785 atoms = self.atoms.copy() 

786 atoms.calc = self 

787 return atoms 

788 

789 @classmethod 

790 def read_atoms(cls, restart, **kwargs): 

791 return cls(restart=restart, label=restart, **kwargs).get_atoms() 

792 

793 def set(self, **kwargs): 

794 """Set parameters like set(key1=value1, key2=value2, ...). 

795 

796 A dictionary containing the parameters that have been changed 

797 is returned. 

798 

799 Subclasses must implement a set() method that will look at the 

800 chaneged parameters and decide if a call to reset() is needed. 

801 If the changed parameters are harmless, like a change in 

802 verbosity, then there is no need to call reset(). 

803 

804 The special keyword 'parameters' can be used to read 

805 parameters from a file.""" 

806 

807 if 'parameters' in kwargs: 

808 filename = kwargs.pop('parameters') 

809 parameters = Parameters.read(filename) 

810 parameters.update(kwargs) 

811 kwargs = parameters 

812 

813 changed_parameters = {} 

814 

815 for key, value in kwargs.items(): 

816 oldvalue = self.parameters.get(key) 

817 if key not in self.parameters or not equal(value, oldvalue): 

818 changed_parameters[key] = value 

819 self.parameters[key] = value 

820 

821 if self.discard_results_on_any_change and changed_parameters: 

822 self.reset() 

823 return changed_parameters 

824 

825 def check_state(self, atoms, tol=1e-15): 

826 """Check for any system changes since last calculation.""" 

827 return compare_atoms( 

828 self.atoms, 

829 atoms, 

830 tol=tol, 

831 excluded_properties=set(self.ignored_changes), 

832 ) 

833 

834 def calculate( 

835 self, atoms=None, properties=['energy'], system_changes=all_changes 

836 ): 

837 """Do the calculation. 

838 

839 properties: list of str 

840 List of what needs to be calculated. Can be any combination 

841 of 'energy', 'forces', 'stress', 'dipole', 'charges', 'magmom' 

842 and 'magmoms'. 

843 system_changes: list of str 

844 List of what has changed since last calculation. Can be 

845 any combination of these six: 'positions', 'numbers', 'cell', 

846 'pbc', 'initial_charges' and 'initial_magmoms'. 

847 

848 Subclasses need to implement this, but can ignore properties 

849 and system_changes if they want. Calculated properties should 

850 be inserted into results dictionary like shown in this dummy 

851 example:: 

852 

853 self.results = {'energy': 0.0, 

854 'forces': np.zeros((len(atoms), 3)), 

855 'stress': np.zeros(6), 

856 'dipole': np.zeros(3), 

857 'charges': np.zeros(len(atoms)), 

858 'magmom': 0.0, 

859 'magmoms': np.zeros(len(atoms))} 

860 

861 The subclass implementation should first call this 

862 implementation to set the atoms attribute and create any missing 

863 directories. 

864 """ 

865 if atoms is not None: 

866 self.atoms = atoms.copy() 

867 if not os.path.isdir(self._directory): 

868 try: 

869 os.makedirs(self._directory) 

870 except FileExistsError as e: 

871 # We can only end up here in case of a race condition if 

872 # multiple Calculators are running concurrently *and* use the 

873 # same _directory, which cannot be expected to work anyway. 

874 msg = ( 

875 'Concurrent use of directory ' 

876 + self._directory 

877 + 'by multiple Calculator instances detected. Please ' 

878 'use one directory per instance.' 

879 ) 

880 raise RuntimeError(msg) from e 

881 

882 @deprecated('Please use `ase.calculators.fd.FiniteDifferenceCalculator`.') 

883 def calculate_numerical_forces(self, atoms, d=0.001): 

884 """Calculate numerical forces using finite difference. 

885 

886 All atoms will be displaced by +d and -d in all directions. 

887 

888 .. deprecated:: 3.24.0 

889 """ 

890 from ase.calculators.fd import calculate_numerical_forces 

891 

892 return calculate_numerical_forces(atoms, eps=d) 

893 

894 @deprecated('Please use `ase.calculators.fd.FiniteDifferenceCalculator`.') 

895 def calculate_numerical_stress(self, atoms, d=1e-6, voigt=True): 

896 """Calculate numerical stress using finite difference. 

897 

898 .. deprecated:: 3.24.0 

899 """ 

900 from ase.calculators.fd import calculate_numerical_stress 

901 

902 return calculate_numerical_stress(atoms, eps=d, voigt=voigt) 

903 

904 def _deprecated_get_spin_polarized(self): 

905 msg = ( 

906 'This calculator does not implement get_spin_polarized(). ' 

907 'In the future, calc.get_spin_polarized() will work only on ' 

908 'calculator classes that explicitly implement this method or ' 

909 'inherit the method via specialized subclasses.' 

910 ) 

911 warnings.warn(msg, FutureWarning) 

912 return False 

913 

914 def band_structure(self): 

915 """Create band-structure object for plotting.""" 

916 from ase.spectrum.band_structure import get_band_structure 

917 

918 # XXX This calculator is supposed to just have done a band structure 

919 # calculation, but the calculator may not have the correct Fermi level 

920 # if it updated the Fermi level after changing k-points. 

921 # This will be a problem with some calculators (currently GPAW), and 

922 # the user would have to override this by providing the Fermi level 

923 # from the selfconsistent calculation. 

924 return get_band_structure(calc=self) 

925 

926 

927class OldShellProfile: 

928 def __init__(self, command): 

929 self.command = command 

930 self.configvars = {} 

931 

932 def execute(self, calc): 

933 if self.command is None: 

934 raise EnvironmentError( 

935 'Please set ${} environment variable '.format( 

936 'ASE_' + self.calc.upper() + '_COMMAND' 

937 ) 

938 + 'or supply the command keyword' 

939 ) 

940 command = self.command 

941 if 'PREFIX' in command: 

942 command = command.replace('PREFIX', calc.prefix) 

943 

944 try: 

945 proc = subprocess.Popen(command, shell=True, cwd=calc.directory) 

946 except OSError as err: 

947 # Actually this may never happen with shell=True, since 

948 # probably the shell launches successfully. But we soon want 

949 # to allow calling the subprocess directly, and then this 

950 # distinction (failed to launch vs failed to run) is useful. 

951 msg = f'Failed to execute "{command}"' 

952 raise EnvironmentError(msg) from err 

953 

954 errorcode = proc.wait() 

955 

956 if errorcode: 

957 path = os.path.abspath(calc.directory) 

958 msg = ( 

959 'Calculator "{}" failed with command "{}" failed in ' 

960 '{} with error code {}'.format( 

961 calc.name, command, path, errorcode 

962 ) 

963 ) 

964 raise CalculationFailed(msg) 

965 

966 

967@dataclass 

968class FileIORules: 

969 """Rules for controlling streams options to external command. 

970 

971 FileIOCalculator will direct stdin and stdout and append arguments 

972 to the calculator command using the specifications on this class. 

973 

974 Currently names can contain "{prefix}" which will be substituted by 

975 calc.prefix. This will go away if/when we can remove prefix.""" 

976 extend_argv: Sequence[str] = tuple() 

977 stdin_name: Optional[str] = None 

978 stdout_name: Optional[str] = None 

979 

980 configspec: Dict[str, Any] = field(default_factory=dict) 

981 

982 def load_config(self, section): 

983 dct = {} 

984 for key, value in self.configspec.items(): 

985 if key in section: 

986 value = section[key] 

987 dct[key] = value 

988 return dct 

989 

990 

991class BadConfiguration(Exception): 

992 pass 

993 

994 

995def _validate_command(command: str) -> str: 

996 # We like to store commands as strings (and call shlex.split() later), 

997 # but we also like to validate them early. This will error out if 

998 # command contains syntax problems and will also normalize e.g. 

999 # multiple spaces: 

1000 try: 

1001 return shlex.join(shlex.split(command)) 

1002 except ValueError as err: 

1003 raise BadConfiguration('Cannot parse command string') from err 

1004 

1005 

1006@dataclass 

1007class StandardProfile: 

1008 command: str 

1009 configvars: Dict[str, Any] = field(default_factory=dict) 

1010 

1011 def __post_init__(self): 

1012 self.command = _validate_command(self.command) 

1013 

1014 def execute(self, calc): 

1015 try: 

1016 self._call(calc, subprocess.check_call) 

1017 except subprocess.CalledProcessError as err: 

1018 directory = Path(calc.directory).resolve() 

1019 msg = (f'Calculator {calc.name} failed with args {err.args} ' 

1020 f'in directory {directory}') 

1021 raise CalculationFailed(msg) from err 

1022 

1023 def execute_nonblocking(self, calc): 

1024 return self._call(calc, subprocess.Popen) 

1025 

1026 @property 

1027 def _split_command(self): 

1028 # XXX Unduplicate common stuff between StandardProfile and 

1029 # that of GenericFileIO 

1030 return shlex.split(self.command) 

1031 

1032 def _call(self, calc, subprocess_function): 

1033 from contextlib import ExitStack 

1034 

1035 directory = Path(calc.directory).resolve() 

1036 fileio_rules = calc.fileio_rules 

1037 

1038 with ExitStack() as stack: 

1039 

1040 def _maybe_open(name, mode): 

1041 if name is None: 

1042 return None 

1043 

1044 name = name.format(prefix=calc.prefix) 

1045 directory = Path(calc.directory) 

1046 return stack.enter_context(open(directory / name, mode)) 

1047 

1048 stdout_fd = _maybe_open(fileio_rules.stdout_name, 'wb') 

1049 stdin_fd = _maybe_open(fileio_rules.stdin_name, 'rb') 

1050 

1051 argv = [*self._split_command, *fileio_rules.extend_argv] 

1052 argv = [arg.format(prefix=calc.prefix) for arg in argv] 

1053 return subprocess_function( 

1054 argv, cwd=directory, 

1055 stdout=stdout_fd, 

1056 stdin=stdin_fd) 

1057 

1058 

1059class FileIOCalculator(Calculator): 

1060 """Base class for calculators that write/read input/output files.""" 

1061 

1062 # Static specification of rules for this calculator: 

1063 fileio_rules: Optional[FileIORules] = None 

1064 

1065 # command: Optional[str] = None 

1066 # 'Command used to start calculation' 

1067 

1068 # Fallback command when nothing else is specified. 

1069 # There will be no fallback in the future; it must be explicitly 

1070 # configured. 

1071 _legacy_default_command: Optional[str] = None 

1072 

1073 cfg = _cfg # Ensure easy access to config for subclasses 

1074 

1075 @classmethod 

1076 def ruleset(cls, *args, **kwargs): 

1077 """Helper for subclasses to define FileIORules.""" 

1078 return FileIORules(*args, **kwargs) 

1079 

1080 def __init__( 

1081 self, 

1082 restart=None, 

1083 ignore_bad_restart_file=Calculator._deprecated, 

1084 label=None, 

1085 atoms=None, 

1086 command=None, 

1087 profile=None, 

1088 **kwargs, 

1089 ): 

1090 """File-IO calculator. 

1091 

1092 command: str 

1093 Command used to start calculation. 

1094 """ 

1095 

1096 super().__init__(restart, ignore_bad_restart_file, label, atoms, 

1097 **kwargs) 

1098 

1099 if profile is None: 

1100 profile = self._initialize_profile(command) 

1101 self.profile = profile 

1102 

1103 @property 

1104 def command(self): 

1105 # XXX deprecate me 

1106 # 

1107 # This is for calculators that invoke Popen directly on 

1108 # self.command instead of letting us (superclass) do it. 

1109 return self.profile.command 

1110 

1111 @command.setter 

1112 def command(self, command): 

1113 self.profile.command = command 

1114 

1115 @classmethod 

1116 def load_argv_profile(cls, cfg, section_name): 

1117 # Helper method to load configuration. 

1118 # This is used by the tests, do not rely on this as it will change. 

1119 try: 

1120 section = cfg.parser[section_name] 

1121 except KeyError: 

1122 raise BadConfiguration(f'No {section_name!r} section') 

1123 

1124 if cls.fileio_rules is not None: 

1125 configvars = cls.fileio_rules.load_config(section) 

1126 else: 

1127 configvars = {} 

1128 

1129 try: 

1130 command = section['command'] 

1131 except KeyError: 

1132 raise BadConfiguration( 

1133 f'No command field in {section_name!r} section') 

1134 

1135 return StandardProfile(command, configvars) 

1136 

1137 def _initialize_profile(self, command): 

1138 if command is None: 

1139 name = 'ASE_' + self.name.upper() + '_COMMAND' 

1140 command = self.cfg.get(name) 

1141 

1142 if command is None and self.name in self.cfg.parser: 

1143 return self.load_argv_profile(self.cfg, self.name) 

1144 

1145 if command is None: 

1146 # XXX issue a FutureWarning if this causes the command 

1147 # to no longer be None 

1148 command = self._legacy_default_command 

1149 

1150 if command is None: 

1151 raise EnvironmentError( 

1152 f'No configuration of {self.name}. ' 

1153 f'Missing section [{self.name}] in configuration') 

1154 

1155 return OldShellProfile(command) 

1156 

1157 def calculate( 

1158 self, atoms=None, properties=['energy'], system_changes=all_changes 

1159 ): 

1160 Calculator.calculate(self, atoms, properties, system_changes) 

1161 self.write_input(self.atoms, properties, system_changes) 

1162 self.execute() 

1163 self.read_results() 

1164 

1165 def execute(self): 

1166 self.profile.execute(self) 

1167 

1168 def write_input(self, atoms, properties=None, system_changes=None): 

1169 """Write input file(s). 

1170 

1171 Call this method first in subclasses so that directories are 

1172 created automatically.""" 

1173 

1174 absdir = os.path.abspath(self.directory) 

1175 if absdir != os.curdir and not os.path.isdir(self.directory): 

1176 os.makedirs(self.directory) 

1177 

1178 def read_results(self): 

1179 """Read energy, forces, ... from output file(s)."""