Coverage for /builds/debichem-team/python-ase/ase/calculators/exciting/exciting.py: 84.29%
70 statements
« prev ^ index » next coverage.py v7.5.3, created at 2025-03-06 04:00 +0000
« prev ^ index » next coverage.py v7.5.3, created at 2025-03-06 04:00 +0000
1"""ASE Calculator for the ground state exciting DFT code.
3Exciting calculator class in this file allow for writing exciting input
4files using ASE Atoms object that allow for the compiled exciting binary
5to run DFT on the geometry/material defined in the Atoms object. Also gives
6access to developer to a lightweight parser (lighter weight than NOMAD or
7the exciting parser in the exciting repository) to capture ground state
8properties.
10Note: excitingtools must be installed using `pip install excitingtools` to
11use this calculator.
12"""
14from os import PathLike
15from pathlib import Path
16from typing import Any, Mapping
18import ase.io.exciting
19from ase.calculators.calculator import PropertyNotImplementedError
20from ase.calculators.exciting.runner import (
21 SimpleBinaryRunner,
22 SubprocessRunResults,
23)
24from ase.calculators.genericfileio import (
25 BaseProfile,
26 CalculatorTemplate,
27 GenericFileIOCalculator,
28)
31class ExcitingProfile(BaseProfile):
32 """Defines all quantities that are configurable for a given machine.
34 Follows the generic pattern BUT currently not used by our calculator as:
35 * species_path is part of the input file in exciting.
36 * OnlyTypo fix part of the profile used in the base class is the run
37 method, which is part of the BinaryRunner class.
38 """
39 configvars = {'species_path'}
41 def __init__(self, command, species_path=None, **kwargs):
42 super().__init__(command, **kwargs)
44 self.species_path = species_path
46 def version(self):
47 """Return exciting version."""
48 # TARP No way to get the version for the binary in use
49 return
51 # Machine specific config files in the config
52 # species_file goes in the config
53 # binary file in the config.
54 # options for that, parallel info dictionary.
55 # Number of threads and stuff like that.
57 def get_calculator_command(self, input_file):
58 """Returns command to run binary as a list of strings."""
59 # input_file unused for exciting, it looks for input.xml in run
60 # directory.
61 if input_file is None:
62 return []
63 else:
64 return [str(input_file)]
67class ExcitingGroundStateTemplate(CalculatorTemplate):
68 """Template for Ground State Exciting Calculator
70 Abstract methods inherited from the base class:
71 * write_input
72 * execute
73 * read_results
74 """
76 parser = {'info.xml': ase.io.exciting.parse_output}
77 output_names = list(parser)
78 # Use frozenset since the CalculatorTemplate enforces it.
79 implemented_properties = frozenset(['energy', 'forces'])
80 _label = 'exciting'
82 def __init__(self):
83 """Initialise with constant class attributes.
85 :param program_name: The DFT program, should always be exciting.
86 :param implemented_properties: What properties should exciting
87 calculate/read from output.
88 """
89 super().__init__('exciting', self.implemented_properties)
90 self.errorname = f'{self._label}.err'
92 @staticmethod
93 def _require_forces(input_parameters):
94 """Expect ASE always wants forces, enforce setting in input_parameters.
96 :param input_parameters: exciting ground state input parameters, either
97 as a dictionary or ExcitingGroundStateInput.
98 :return: Ground state input parameters, with "compute
99 forces" set to true.
100 """
101 from excitingtools import ExcitingGroundStateInput
103 input_parameters = ExcitingGroundStateInput(input_parameters)
104 input_parameters.tforce = True
105 return input_parameters
107 def write_input(
108 self,
109 profile: ExcitingProfile, # ase test linter enforces method signatures
110 # be consistent with the
111 # abstract method that it implements
112 directory: PathLike,
113 atoms: ase.Atoms,
114 parameters: dict,
115 properties=None,
116 ):
117 """Write an exciting input.xml file based on the input args.
119 :param profile: an Exciting code profile
120 :param directory: Directory in which to run calculator.
121 :param atoms: ASE atoms object.
122 :param parameters: exciting ground state input parameters, in a
123 dictionary. Expect species_path, title and ground_state data,
124 either in an object or as dict.
125 :param properties: Base method's API expects the physical properties
126 expected from a ground state calculation, for example energies
127 and forces. For us this is not used.
128 """
129 # Create a copy of the parameters dictionary so we don't
130 # modify the callers dictionary.
131 parameters_dict = parameters
132 assert set(parameters_dict.keys()) == {
133 'title', 'species_path', 'ground_state_input',
134 'properties_input'}, \
135 'Keys should be defined by ExcitingGroundState calculator'
136 file_name = Path(directory) / 'input.xml'
137 species_path = parameters_dict.pop('species_path')
138 title = parameters_dict.pop('title')
139 # We can also pass additional parameters which are actually called
140 # properties in the exciting input xml. We don't use this term
141 # since ASE use properties to refer to results of a calculation
142 # (e.g. force, energy).
143 if 'properties_input' not in parameters_dict:
144 parameters_dict['properties_input'] = None
146 ase.io.exciting.write_input_xml_file(
147 file_name=file_name, atoms=atoms,
148 ground_state_input=parameters_dict['ground_state_input'],
149 species_path=species_path, title=title,
150 properties_input=parameters_dict['properties_input'])
152 def execute(
153 self, directory: PathLike,
154 profile) -> SubprocessRunResults:
155 """Given an exciting calculation profile, execute the calculation.
157 :param directory: Directory in which to execute the calculator
158 exciting_calculation: Base method `execute` expects a profile,
159 however it is simply used to execute the program, therefore we
160 just pass a SimpleBinaryRunner.
161 :param profile: This name comes from the superclass CalculatorTemplate.
162 It contains machine specific information to run the
163 calculation.
165 :return: Results of the subprocess.run command.
166 """
167 return profile.run(directory, f"{directory}/input.xml", None,
168 erorrfile=self.errorname)
170 def read_results(self, directory: PathLike) -> Mapping[str, Any]:
171 """Parse results from each ground state output file.
173 Note we allow for the ability for there to be multiple output files.
175 :param directory: Directory path to output file from exciting
176 simulation.
177 :return: Dictionary containing important output properties.
178 """
179 results = {}
180 for file_name in self.output_names:
181 full_file_path = Path(directory) / file_name
182 result: dict = self.parser[file_name](full_file_path)
183 results.update(result)
184 return results
186 def load_profile(self, cfg, **kwargs):
187 """ExcitingProfile can be created via a config file.
189 Alternative to this method the profile can be created with it's
190 init method. This method allows for more settings to be passed.
191 """
192 return ExcitingProfile.from_config(cfg, self.name, **kwargs)
195class ExcitingGroundStateResults:
196 """Exciting Ground State Results."""
198 def __init__(self, results: dict) -> None:
199 self.results = results
200 self.final_scl_iteration = list(results['scl'].keys())[-1]
202 def total_energy(self) -> float:
203 """Return total energy of system."""
204 # TODO(Alex) We should a common list of keys somewhere
205 # such that parser -> results -> getters are consistent
206 return float(
207 self.results['scl'][self.final_scl_iteration]['Total energy']
208 )
210 def band_gap(self) -> float:
211 """Return the estimated fundamental gap from the exciting sim."""
212 return float(
213 self.results['scl'][self.final_scl_iteration][
214 'Estimated fundamental gap'
215 ]
216 )
218 def forces(self):
219 """Return forces present on the system.
221 Currently, not all exciting simulations return forces. We leave this
222 definition for future revisions.
223 """
224 raise PropertyNotImplementedError
226 def stress(self):
227 """Get the stress on the system.
229 Right now exciting does not yet calculate the stress on the system so
230 this won't work for the time being.
231 """
232 raise PropertyNotImplementedError
235class ExcitingGroundStateCalculator(GenericFileIOCalculator):
236 """Class for the ground state calculation.
238 :param runner: Binary runner that will execute an exciting calculation and
239 return a result.
240 :param ground_state_input: dictionary of ground state settings for example
241 {'rgkmax': 8.0, 'autormt': True} or an object of type
242 ExcitingGroundStateInput.
243 :param directory: Directory in which to run the job.
244 :param species_path: Path to the location of exciting's species files.
245 :param title: job name written to input.xml
247 :return: Results returned from running the calculate method.
250 Typical usage:
252 gs_calculator = ExcitingGroundState(runner, ground_state_input)
254 results: ExcitingGroundStateResults = gs_calculator.calculate(
255 atoms: Atoms)
256 """
258 def __init__(
259 self,
260 *,
261 runner: SimpleBinaryRunner,
262 ground_state_input,
263 directory='./',
264 species_path='./',
265 title='ASE-generated input',
266 ):
267 self.runner = runner
268 # Package data to be passed to
269 # ExcitingGroundStateTemplate.write_input(..., input_parameters, ...)
270 # Structure not included, as it's passed when one calls .calculate
271 # method directly
272 self.exciting_inputs = {
273 'title': title,
274 'species_path': species_path,
275 'ground_state_input': ground_state_input,
276 }
277 self.directory = Path(directory)
279 # GenericFileIOCalculator expects a `profile`
280 # containing machine-specific settings, however, in exciting's case,
281 # the species file are defined in the input XML (hence passed in the
282 # parameters argument) and the only other machine-specific setting is
283 # the BinaryRunner. Furthermore, in GenericFileIOCalculator.calculate,
284 # profile is only used to provide a run method. We therefore pass the
285 # BinaryRunner in the place of a profile.
286 super().__init__(
287 profile=runner,
288 template=ExcitingGroundStateTemplate(),
289 directory=directory,
290 parameters=self.exciting_inputs,
291 )