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

96 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-23 12:50 +0000

1import os 

2import shutil 

3import subprocess 

4import tempfile 

5import warnings 

6from typing import List, Optional, Union 

7 

8import numpy as np 

9from ase import Atoms 

10from ase.calculators.singlepoint import SinglePointCalculator 

11from ase.units import GPa 

12 

13from calorine.env import calorine_getenv 

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

15from calorine.nep.model import Model, read_model 

16from calorine.nep.nep import set_default_cell 

17from calorine.nep.tensor_conventions import ASE_VOIGT6_ORDER, reduced6_to_full_3x3 

18 

19 

20def batch_predict_properties( 

21 structures: List[Atoms], 

22 model: Union[str, Model], 

23 command: Optional[str] = None, 

24 directory: Optional[str] = None, 

25) -> List[Atoms]: 

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

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

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

29 substantially faster than evaluating structures one at a time with 

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

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

32 transferred to the GPU in a single pass. 

33 

34 Parameters 

35 ---------- 

36 structures 

37 Structures for which to evaluate properties. 

38 model 

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

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

41 command 

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

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

44 environment variable if set. 

45 directory 

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

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

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

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

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

51 <calorine.nep.read_structures>`). 

52 

53 Returns 

54 ------- 

55 list of Atoms 

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

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

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

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

60 ``forces``, and ``stress``, plus ``charges`` and 

61 ``born_effective_charges`` for qNEP models, or 

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

63 :attr:`structures` are not modified. 

64 """ 

65 if len(structures) == 0: 

66 return [] 

67 

68 if isinstance(model, Model): 

69 model_obj = model 

70 else: 

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

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

73 model_obj = read_model(model) 

74 

75 if model_obj.model_type in ('potential', 'potential_with_charges'): 

76 model_type_int = 0 

77 elif model_obj.model_type == 'dipole': 

78 model_type_int = 1 

79 elif model_obj.model_type == 'polarizability': 79 ↛ 82line 79 didn't jump to line 82 because the condition on line 79 was always true

80 model_type_int = 2 

81 else: 

82 raise ValueError(f'Unknown model_type: {model_obj.model_type}') 

83 charge_mode = model_obj.charge_mode 

84 

85 parameters = dict(model_obj.training_parameters) 

86 parameters['prediction'] = 1 

87 parameters['model_type'] = model_type_int 

88 if charge_mode: 

89 parameters['charge_mode'] = charge_mode 

90 

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

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

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

94 use_temporary_directory = directory is None 

95 if use_temporary_directory: 

96 directory = tempfile.mkdtemp() 

97 else: 

98 os.makedirs(directory, exist_ok=True) 

99 if os.listdir(directory): 

100 warnings.warn(f'{directory} is not empty.') 

101 

102 try: 

103 if isinstance(model, Model): 

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

105 else: 

106 shutil.copy2(model, os.path.join(directory, 'nep.txt')) 

107 write_nepfile(parameters, directory) 

108 

109 prepared_structures = [] 

110 for structure in structures: 

111 prepared = structure.copy() 

112 if prepared.cell.rank == 0: 

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

114 set_default_cell(prepared) 

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

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

117 if charge_mode: 

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

119 prepared_structures.append(prepared) 

120 with warnings.catch_warnings(): 

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

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

123 warnings.filterwarnings( 

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

125 category=UserWarning) 

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

127 

128 run_command = command or calorine_getenv('NEP_COMMAND') 

129 try: 

130 completed = subprocess.run( 

131 [run_command], cwd=directory, capture_output=True, text=True) 

132 except OSError as e: 

133 raise RuntimeError(f'Failed to run `{run_command}` in prediction mode: {e}') from e 

134 if completed.returncode != 0: 

135 raise RuntimeError( 

136 f'Failed to run `{run_command}` in prediction mode:\n{completed.stderr}') 

137 

138 with warnings.catch_warnings(): 

139 # prediction mode never writes a test.xyz; the resulting warning is expected noise 

140 warnings.filterwarnings( 

141 'ignore', message=r'File .*test\.xyz not found\.', category=UserWarning) 

142 predicted_structures, _ = read_structures(directory) 

143 finally: 

144 if use_temporary_directory: 

145 shutil.rmtree(directory) 

146 

147 results_structures = [] 

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

149 natoms = len(original) 

150 results = {} 

151 if model_type_int == 0: 

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

153 # multiply by natoms to match the ASE convention. 

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

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

156 # read_structures() already converts `stress_predicted` to 

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

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

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

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

161 if charge_mode: 

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

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

164 elif model_type_int == 1: 

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

166 elif model_type_int == 2: 166 ↛ 173line 166 didn't jump to line 173 because the condition on line 166 was always true

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

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

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

170 

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

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

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

174 new_atoms = original.copy() 

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

176 if polarizability is not None: 

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

178 results_structures.append(new_atoms) 

179 

180 return results_structures