Coverage for ase / calculators / exciting / exciting.py: 87.67%

73 statements  

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

1# fmt: off 

2 

3"""ASE Calculator for the ground state exciting DFT code. 

4 

5Exciting calculator class in this file allow for writing exciting input 

6files using ASE Atoms object that allow for the compiled exciting binary 

7to run DFT on the geometry/material defined in the Atoms object. Also gives 

8access to developer to a lightweight parser (lighter weight than NOMAD or 

9the exciting parser in the exciting repository) to capture ground state 

10properties. 

11 

12Note: excitingtools must be installed using `pip install excitingtools` to 

13use this calculator. 

14""" 

15 

16from collections.abc import Mapping 

17from os import PathLike 

18from pathlib import Path 

19from typing import Any 

20 

21import ase.io.exciting 

22from ase.calculators.calculator import PropertyNotImplementedError 

23from ase.calculators.exciting.runner import ( 

24 SimpleBinaryRunner, 

25 SubprocessRunResults, 

26) 

27from ase.calculators.genericfileio import ( 

28 BaseProfile, 

29 CalculatorTemplate, 

30 GenericFileIOCalculator, 

31) 

32 

33 

34class ExcitingProfile(BaseProfile): 

35 """Defines all quantities that are configurable for a given machine. 

36 

37 Follows the generic pattern BUT currently not used by our calculator as: 

38 * species_path is part of the input file in exciting. 

39 * OnlyTypo fix part of the profile used in the base class is the run 

40 method, which is part of the BinaryRunner class. 

41 """ 

42 configvars = {'species_path'} 

43 

44 def __init__(self, command, species_path=None, **kwargs): 

45 super().__init__(command, **kwargs) 

46 

47 self.species_path = species_path 

48 

49 def version(self): 

50 """Return exciting version.""" 

51 # TARP No way to get the version for the binary in use 

52 return 

53 

54 # Machine specific config files in the config 

55 # species_file goes in the config 

56 # binary file in the config. 

57 # options for that, parallel info dictionary. 

58 # Number of threads and stuff like that. 

59 

60 def get_calculator_command(self, input_file): 

61 """Returns command to run binary as a list of strings.""" 

62 # input_file unused for exciting, it looks for input.xml in run 

63 # directory. 

64 if input_file is None: 

65 return [] 

66 else: 

67 return [str(input_file)] 

68 

69 

70class ExcitingGroundStateTemplate(CalculatorTemplate): 

71 """Template for Ground State Exciting Calculator 

72 

73 Abstract methods inherited from the base class: 

74 * write_input 

75 * execute 

76 * read_results 

77 """ 

78 

79 parser = {'info.xml': ase.io.exciting.parse_output} 

80 output_names = list(parser) 

81 # Use frozenset since the CalculatorTemplate enforces it. 

82 implemented_properties = frozenset(['energy', 'forces']) 

83 _label = 'exciting' 

84 

85 def __init__(self): 

86 """Initialise with constant class attributes. 

87 

88 :param program_name: The DFT program, should always be exciting. 

89 :param implemented_properties: What properties should exciting 

90 calculate/read from output. 

91 """ 

92 super().__init__('exciting', self.implemented_properties) 

93 self.errorname = f'{self._label}.err' 

94 

95 @staticmethod 

96 def _require_forces(input_parameters): 

97 """Expect ASE always wants forces, enforce setting in input_parameters. 

98 

99 :param input_parameters: exciting ground state input parameters, either 

100 as a dictionary or ExcitingGroundStateInput. 

101 :return: Ground state input parameters, with "compute 

102 forces" set to true. 

103 """ 

104 from excitingtools import ExcitingGroundStateInput 

105 

106 input_parameters = ExcitingGroundStateInput(input_parameters) 

107 input_parameters.tforce = True 

108 return input_parameters 

109 

110 def write_input( 

111 self, 

112 profile: ExcitingProfile, # ase test linter enforces method signatures 

113 # be consistent with the 

114 # abstract method that it implements 

115 directory: PathLike, 

116 atoms: ase.Atoms, 

117 parameters: dict, 

118 properties=None, 

119 ): 

120 """Write an exciting input.xml file based on the input args. 

121 

122 :param profile: an Exciting code profile 

123 :param directory: Directory in which to run calculator. 

124 :param atoms: ASE atoms object. 

125 :param parameters: exciting ground state input parameters, in a 

126 dictionary. Expect species_path, title and ground_state data, 

127 either in an object or as dict. 

128 :param properties: Base method's API expects the physical properties 

129 expected from a ground state calculation, for example energies 

130 and forces. For us this is not used. 

131 """ 

132 # Create a copy of the parameters dictionary so we don't 

133 # modify the callers dictionary. 

134 parameters_dict = parameters 

135 required_keys = { 

136 'title', 'species_path', 'ground_state_input'} 

137 assert required_keys <= set(parameters_dict) 

138 file_name = Path(directory) / 'input.xml' 

139 species_path = parameters_dict.pop('species_path') 

140 title = parameters_dict.pop('title') 

141 # We can also pass additional parameters which are actually called 

142 # properties in the exciting input xml. We don't use this term 

143 # since ASE use properties to refer to results of a calculation 

144 # (e.g. force, energy). 

145 if 'properties_input' not in parameters_dict: 

146 parameters_dict['properties_input'] = None 

147 

148 ase.io.exciting.write_input_xml_file( 

149 file_name=file_name, atoms=atoms, 

150 ground_state_input=parameters_dict['ground_state_input'], 

151 species_path=species_path, title=title, 

152 properties_input=parameters_dict['properties_input']) 

153 

154 def execute( 

155 self, directory: PathLike, 

156 profile) -> SubprocessRunResults: 

157 """Given an exciting calculation profile, execute the calculation. 

158 

159 When executing an exciting calculation, you need to call run on an 

160 exciting binary runner which is stored as the profile variable. The 

161 binary runner at initialization will set all of the directory 

162 information so we don't use this variable again here. 

163 

164 :param directory: Not used but comes from the base class. 

165 :param profile: This name comes from the superclass CalculatorTemplate. 

166 It should be an instance of an exciting.runner.SimpleBinaryRunner. 

167 

168 :return: Results of the subprocess.run command. 

169 """ 

170 return profile.run() 

171 

172 def read_results(self, directory: PathLike) -> Mapping[str, Any]: 

173 """Parse results from each ground state output file. 

174 

175 Note we allow for the ability for there to be multiple output files. 

176 

177 :param directory: Directory path to output file from exciting 

178 simulation. 

179 :return: dictionary of results. This can be fed into 

180 ExcitingGroundStateResults if the user wants to access properties 

181 more easily. 

182 """ 

183 results = {} 

184 for file_name in self.output_names: 

185 full_file_path = Path(directory) / file_name 

186 result: dict = self.parser[file_name](full_file_path) 

187 results.update(result) 

188 return results 

189 

190 def load_profile(self, cfg, **kwargs): 

191 """ExcitingProfile can be created via a config file. 

192 

193 Alternative to this method the profile can be created with it's 

194 init method. This method allows for more settings to be passed. 

195 """ 

196 return ExcitingProfile.from_config(cfg, self.name, **kwargs) 

197 

198 

199class ExcitingGroundStateResults: 

200 """Exciting Ground State Results.""" 

201 

202 def __init__(self, results: dict) -> None: 

203 self.results = results 

204 self.final_scl_iteration = list(results['scl'].keys())[-1] 

205 

206 def total_energy(self) -> float: 

207 """Return total energy of system.""" 

208 # TODO(Alex) We should a common list of keys somewhere 

209 # such that parser -> results -> getters are consistent 

210 return float( 

211 self.results['scl'][self.final_scl_iteration][ 

212 'Total energy'] 

213 ) 

214 

215 def band_gap(self) -> float: 

216 """Return the estimated fundamental gap from the exciting sim.""" 

217 return float( 

218 self.results['scl'][self.final_scl_iteration][ 

219 'Estimated fundamental gap' 

220 ] 

221 ) 

222 

223 def forces(self): 

224 """Return forces present on the system. 

225 

226 Currently, not all exciting simulations return forces. We leave this 

227 definition for future revisions. 

228 """ 

229 raise PropertyNotImplementedError 

230 

231 def stress(self): 

232 """Get the stress on the system. 

233 

234 Right now exciting does not yet calculate the stress on the system so 

235 this won't work for the time being. 

236 """ 

237 raise PropertyNotImplementedError 

238 

239 

240class ExcitingGroundStateCalculator(GenericFileIOCalculator): 

241 """Class for the ground state calculation. 

242 

243 :param runner: Binary runner that will execute an exciting calculation and 

244 return a result. 

245 :param ground_state_input: dictionary of ground state settings for example 

246 {'rgkmax': 8.0, 'autormt': True} or an object of type 

247 ExcitingGroundStateInput. 

248 :param directory: Directory in which to run the job. 

249 :param species_path: Path to the location of exciting's species files. 

250 :param title: job name written to input.xml 

251 

252 :return: Results returned from running the calculate method. 

253 

254 

255 Typical usage: 

256 

257 gs_calculator = ExcitingGroundState(runner, ground_state_input) 

258 

259 results: ExcitingGroundStateResults = gs_calculator.calculate( 

260 atoms: Atoms) 

261 """ 

262 

263 def __init__( 

264 self, 

265 *, 

266 runner: SimpleBinaryRunner, 

267 ground_state_input, 

268 directory='./', 

269 species_path='./', 

270 title='ASE-generated input', 

271 parameters: dict[str, Any] | None = None, 

272 ): 

273 self.runner = runner 

274 # Package data to be passed to 

275 # ExcitingGroundStateTemplate.write_input(..., input_parameters, ...) 

276 # Structure not included, as it's passed when one calls .calculate 

277 # method directly. 

278 required_params = { 

279 'title': title, 

280 'species_path': species_path, 

281 'ground_state_input': ground_state_input} 

282 # Set parameters to an empty dict if it is None (or Falsey). This 

283 # is needed for the next line. 

284 parameters = parameters or {} 

285 # Add key, value paris from required_params if they are not defined 

286 # in parmaters. 

287 parameters = required_params | parameters 

288 # GenericFileIOCalculator expects a `profile` 

289 # containing machine-specific settings, however, in exciting's case, 

290 # the species file are defined in the input XML (hence passed in the 

291 # parameters argument) and the only other machine-specific setting is 

292 # the BinaryRunner. Furthermore, in GenericFileIOCalculator.calculate, 

293 # profile is only used to provide a run method. We therefore pass the 

294 # BinaryRunner in the place of a profile. 

295 super().__init__( 

296 profile=runner, 

297 template=ExcitingGroundStateTemplate(), 

298 directory=directory, 

299 parameters=parameters, 

300 )