Coverage for calorine/tools/prediction.py: 100%

84 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-08 12:52 +0000

1import os 

2import shutil 

3import tempfile 

4import warnings 

5 

6import numpy as np 

7from ase import Atoms 

8from ase.calculators.singlepoint import SinglePointCalculator 

9from ase.units import GPa 

10 

11from calorine.env import calorine_getenv 

12from calorine.nep.io import read_structures, write_nepfile, write_structures 

13from calorine.nep.model import Model, read_model 

14from calorine.nep.nep import _run_nep_prediction, set_default_cell 

15from calorine.nep.tensor_conventions import ASE_VOIGT6_ORDER, reduced6_to_full_3x3 

16 

17 

18# Files that an earlier prediction run in the same directory leaves behind: the 

19# input files written here and the `*_train.out` files the `nep` executable 

20# produces, the latter covered by the `.out` suffix. Mirrors 

21# `_PREVIOUS_RUN_FILES` in calorine/calculators/gpunep.py, but lists the files of 

22# the `nep` executable rather than those of `gpumd`. 

23_PREVIOUS_RUN_FILES = ('nep.in', 'nep.txt', 'train.xyz') 

24 

25 

26def _find_previous_run_files(directory: str) -> list[str]: 

27 """Return the names of the files in :attr:`directory` that indicate an 

28 earlier prediction run took place there. 

29 

30 The presence of such files, rather than a non-empty directory, is what 

31 signals that results may be read back from an earlier run by mistake. A 

32 directory holding only unrelated files is not reported. 

33 

34 Parameters 

35 ---------- 

36 directory 

37 Directory to inspect. 

38 

39 Returns 

40 ------- 

41 Sorted names of the files that indicate an earlier run, empty if there 

42 are none. 

43 

44 Example 

45 ------- 

46 >>> # xdoctest: +SKIP 

47 >>> _find_previous_run_files('some_directory_with_an_energy_train_out_file') 

48 ['energy_train.out'] 

49 """ 

50 return sorted(filename for filename in os.listdir(directory) 

51 if filename in _PREVIOUS_RUN_FILES or filename.endswith('.out')) 

52 

53 

54def batch_predict_properties( 

55 structures: list[Atoms], 

56 model: str | Model, 

57 command: str | None = None, 

58 directory: str | None = None, 

59) -> list[Atoms]: 

60 """Evaluates NEP model properties for a list of structures in a single pass, 

61 using the ``prediction`` mode of the ``nep`` executable (see `here 

62 <https://gpumd.org/nep/input_parameters/prediction.html>`__). This is 

63 substantially faster than evaluating structures one at a time with 

64 :class:`CPUNEP <calorine.calculators.CPUNEP>` or 

65 :class:`GPUNEP <calorine.calculators.GPUNEP>`, since all structures are 

66 transferred to the GPU in a single pass. 

67 

68 Parameters 

69 ---------- 

70 structures 

71 Structures for which to evaluate properties. 

72 model 

73 Either a path to a NEP model in ``nep.txt`` format, or a 

74 :class:`Model <calorine.nep.model.Model>` object. 

75 command 

76 Command used to invoke the ``nep`` executable. 

77 Default: ``nep``, or the value of the ``CALORINE_NEP_COMMAND`` 

78 environment variable if set. 

79 directory 

80 Directory in which to run ``nep``. If ``None``, a temporary directory 

81 is created and removed once the calculation is finished. If 

82 specified, the directory is created if needed and is *not* deleted 

83 afterward, which is useful for debugging or for further analysis of 

84 the raw ``nep`` output files (e.g. via :func:`read_structures 

85 <calorine.nep.read_structures>`). 

86 The run writes its own ``nep.txt`` there, so a model file named 

87 ``nep.txt`` in that directory is refused. 

88 

89 Returns 

90 ------- 

91 list of Atoms 

92 A new list of :class:`Atoms <ase.Atoms>` objects, in the same order 

93 as :attr:`structures`, each with a :class:`SinglePointCalculator 

94 <ase.calculators.singlepoint.SinglePointCalculator>` attached 

95 exposing the predicted properties in the standard way (``energy``, 

96 ``forces``, and ``stress``, plus ``charges`` and 

97 ``born_effective_charges`` for qNEP models, or 

98 ``dipole``/``polarizability`` for TNEP models). The input 

99 :attr:`structures` are not modified. 

100 """ 

101 if len(structures) == 0: 

102 return [] 

103 

104 if isinstance(model, Model): 

105 model_obj = model 

106 else: 

107 if not os.path.exists(model): 

108 raise FileNotFoundError(f'{model} does not exist.') 

109 if directory is not None: 

110 run_model_file = os.path.join(directory, 'nep.txt') 

111 if os.path.exists(run_model_file) and os.path.samefile(model, run_model_file): 

112 raise ValueError(f'{model} is the nep.txt of the run directory {directory},' 

113 ' which the run overwrites; pass another directory.') 

114 model_obj = read_model(model) 

115 

116 # `training_parameters` carries every nep.in keyword that describes the model, including 

117 # `model_type` and, for a charge-aware model, `charge_mode` and the muNEP head layout 

118 parameters = dict(model_obj.training_parameters) 

119 parameters['prediction'] = 1 

120 model_type_int = parameters['model_type'] 

121 # This is the set the dispatch below handles, which is why it is written here rather 

122 # than read from `_MODEL_TYPE_TO_INT`: a model type added to that mapping has to reach 

123 # this guard as a rejection until an arm is written for it. 

124 if model_type_int not in (0, 1, 2): 

125 raise ValueError(f'batch_predict_properties does not support model_type {model_type_int}') 

126 charge_mode = parameters.get('charge_mode', 0) 

127 

128 # If no directory is given, run in a temporary one that is cleaned up 

129 # afterward; otherwise keep the nep.in/nep.txt/train.xyz/*_train.out files 

130 # around for debugging or further analysis, mirroring GPUNEP. 

131 use_temporary_directory = directory is None 

132 if use_temporary_directory: 

133 directory = tempfile.mkdtemp() 

134 else: 

135 os.makedirs(directory, exist_ok=True) 

136 previous_run_files = _find_previous_run_files(directory) 

137 if previous_run_files: 

138 warnings.warn(f'{directory} already contains files from an earlier run: ' 

139 f'{", ".join(previous_run_files)}. Results may be read from ' 

140 'those rather than from the run about to take place.') 

141 

142 try: 

143 model_obj.write(os.path.join(directory, 'nep.txt')) 

144 write_nepfile(os.path.join(directory, 'nep.in'), parameters) 

145 

146 prepared_structures = [] 

147 for structure in structures: 

148 prepared = structure.copy() 

149 if prepared.cell.rank == 0: 

150 warnings.warn('Using default unit cell (cubic with side 100 Å).') 

151 set_default_cell(prepared) 

152 forces = np.zeros((len(prepared), 3)) 

153 prepared.calc = SinglePointCalculator(prepared, energy=0.0, forces=forces) 

154 if charge_mode: 

155 prepared.arrays['bec'] = np.zeros((len(prepared), 9)) 

156 prepared_structures.append(prepared) 

157 with warnings.catch_warnings(): 

158 # prepared_structures carry a placeholder zero energy/forces (stress is not 

159 # part of that placeholder), so the resulting warning is expected noise 

160 warnings.filterwarnings( 

161 'ignore', message='Failed to retrieve stresses for structure', 

162 category=UserWarning) 

163 write_structures(os.path.join(directory, 'train.xyz'), prepared_structures) 

164 

165 _run_nep_prediction(command or calorine_getenv('NEP_COMMAND'), directory) 

166 

167 predicted_structures, _ = read_structures(directory) 

168 finally: 

169 if use_temporary_directory: 

170 shutil.rmtree(directory) 

171 

172 results_structures = [] 

173 for original, predicted in zip(structures, predicted_structures): 

174 natoms = len(original) 

175 results = {} 

176 if model_type_int == 0: 

177 # `energy_predicted` is a per-atom average, not a per-structure total; 

178 # multiply by natoms to match the ASE convention. 

179 results['energy'] = float(predicted.info['energy_predicted'][0]) * natoms 

180 results['forces'] = predicted.arrays['force_predicted'] 

181 # read_structures() already converts `stress_predicted` to 

182 # ASE-Voigt order; it's also already normalized by the 

183 # (GPUMD-internal) cell volume and given in GPa, matching the 

184 # convention used for GPUNEP's thermo.out parsing. 

185 results['stress'] = -np.array(predicted.info['stress_predicted']) * GPa 

186 if charge_mode: 

187 results['charges'] = predicted.arrays['charge_predicted'][:, 0] 

188 results['born_effective_charges'] = predicted.arrays['bec_predicted'] 

189 elif model_type_int == 1: 

190 results['dipole'] = np.array(predicted.info['dipole_predicted']) * natoms 

191 else: 

192 # Also already converted to ASE-Voigt order by read_structures(). 

193 p = np.array(predicted.info['polarizability_predicted']) * natoms 

194 results['polarizability'] = reduced6_to_full_3x3(p, order=ASE_VOIGT6_ORDER) 

195 

196 # `polarizability` is not among ASE's SinglePointCalculator-recognized 

197 # properties, so it has to be added to `results` after construction. 

198 polarizability = results.pop('polarizability', None) 

199 new_atoms = original.copy() 

200 new_atoms.calc = SinglePointCalculator(new_atoms, **results) 

201 if polarizability is not None: 

202 new_atoms.calc.results['polarizability'] = polarizability 

203 results_structures.append(new_atoms) 

204 

205 return results_structures