andreasburger commited on
Commit
06c178e
·
verified ·
1 Parent(s): f98723b

Delete equiformer_ase_calculator.py

Browse files
Files changed (1) hide show
  1. equiformer_ase_calculator.py +0 -414
equiformer_ase_calculator.py DELETED
@@ -1,414 +0,0 @@
1
- """
2
- ASE Calculator wrapper for Equiformer model.
3
- """
4
-
5
- from typing import Optional
6
- import numpy as np
7
- import yaml
8
- import os
9
-
10
- import torch
11
- from torch_geometric.data import Data as TGData
12
- from torch_geometric.data import Batch
13
- from ase import Atoms
14
- from ase.calculators.calculator import Calculator, all_changes
15
- from ase.data import atomic_numbers
16
- from ase.calculators.singlepoint import SinglePointCalculator as sp
17
- from ase.constraints import FixAtoms
18
- from torch_scatter import scatter_mean
19
-
20
- from ocpmodels.datasets import data_list_collater
21
- from ocpmodels.preprocessing import AtomsToGraphs
22
- from ocpmodels.common.relaxation.ase_utils import (
23
- batch_to_atoms,
24
- ase_atoms_to_torch_geometric,
25
- )
26
-
27
- from nets.equiformer_v2.equiformer_v2_oc20 import EquiformerV2_OC20
28
- from nets.prediction_utils import compute_extra_props
29
-
30
-
31
- class EquiformerASECalculator(Calculator):
32
- """
33
- Equiformer ASE Calculator.
34
-
35
- Might need to reimplement EquiformerASECalculator based on:
36
- ocpmodels/common/relaxation/ase_utils.py
37
-
38
- Args:
39
- checkpoint_path: Path to the Equiformer model checkpoint
40
- device: Optional device specification (defaults to auto-detect)
41
- """
42
-
43
- def __init__(
44
- self,
45
- checkpoint_path: Optional[str] = None,
46
- device: Optional[torch.device] = None,
47
- project_root: str = None,
48
- **kwargs,
49
- ):
50
- """
51
- Initialize the Equiformer calculator.
52
-
53
- Args:
54
- checkpoint_path: Path to the trained Equiformer checkpoint file
55
- device: Optional device specification (defaults to auto-detect)
56
- **kwargs: Additional keyword arguments for parent Calculator class
57
- """
58
- Calculator.__init__(self, **kwargs)
59
- self.results = {}
60
-
61
- # Set device
62
- self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
63
-
64
- # Load model
65
- if project_root is None:
66
- project_root = os.path.dirname(os.path.dirname(__file__))
67
- config_path = os.path.join(project_root, "configs/equiformer_v2.yaml")
68
- with open(config_path, "r") as file:
69
- config = yaml.safe_load(file)
70
- model_config = config["model"]
71
- self.model = EquiformerV2_OC20(**model_config)
72
-
73
- if checkpoint_path is None:
74
- checkpoint_path = os.path.join(project_root, "ckpt/eqv2.ckpt")
75
- state_dict = torch.load(checkpoint_path, weights_only=True)["state_dict"]
76
- state_dict = {k.replace("potential.", ""): v for k, v in state_dict.items()}
77
- self.model.load_state_dict(state_dict, strict=False)
78
- self.model = self.model.to(self.device)
79
- self.model.eval()
80
-
81
- # Set implemented properties
82
- self.implemented_properties = ["energy", "forces", "hessian"]
83
-
84
- # ocpmodels/common/relaxation/ase_utils.py
85
- self.a2g = AtomsToGraphs(
86
- max_neigh=self.model.max_neighbors,
87
- radius=self.model.cutoff,
88
- r_energy=False,
89
- r_forces=False,
90
- r_distances=False,
91
- r_edges=False,
92
- r_pbc=True,
93
- )
94
-
95
- def forward(self, atoms, hessian=False):
96
- """
97
- Forward pass for the Equiformer calculator.
98
- If hessian is True, it will compute the Hessian via autograd and eigenvalues/eigenvectors.
99
- Otherwise, it will only compute the energy and forces.
100
- """
101
- properties = ["energy", "forces"]
102
- if hessian:
103
- properties += ["hessian", "eigen"]
104
- self.calculate(atoms, properties=properties)
105
- return self.results
106
-
107
- def calculate(self, atoms=None, properties=None, system_changes=all_changes):
108
- """
109
- Calculate properties for the given atoms.
110
-
111
- You can get the
112
-
113
- Args:
114
- atoms: ASE Atoms object
115
- properties: List of properties to compute (used by ASE internally)
116
- system_changes: System changes since last calculation (used by ASE internally)
117
- """
118
- # Call base class to set atoms attribute
119
- Calculator.calculate(self, atoms)
120
-
121
- # ocpmodels/common/relaxation/ase_utils.py
122
- # data_object = self.a2g.convert(atoms)
123
- # batch = data_list_collater([data_object], otf_graph=True)
124
-
125
- # Convert ASE atoms to torch_geometric format
126
- batch = ase_atoms_to_torch_geometric(atoms)
127
- batch = batch.to(self.device)
128
-
129
- if properties is None:
130
- properties = []
131
- if "eigen" in properties:
132
- properties.append("hessian")
133
-
134
- # Prepare batch with extra properties
135
- batch = compute_extra_props(batch, pos_require_grad="hessian" in properties)
136
-
137
- # Run prediction
138
- with torch.enable_grad():
139
- energy, forces, eigenoutputs = self.model.forward(batch, eigen=True)
140
-
141
- # Store results
142
- self.results = {}
143
-
144
- # Energy is per molecule, extract scalar value
145
- self.results["energy"] = float(energy.detach().cpu().item())
146
-
147
- # Forces shape: [n_atoms, 3]
148
- self.results["forces"] = forces.detach().cpu().numpy()
149
-
150
- # predicted eigenvalues and eigenvectors of the Hessian
151
- for key in ["eigval_1", "eigval_2", "eigvec_1", "eigvec_2"]:
152
- if key in eigenoutputs:
153
- self.results[key] = eigenoutputs[key].detach().cpu().numpy()
154
-
155
- # Compute the Hessian via autodiff on the fly
156
- if "hessian" in properties:
157
- # 3D coordinates -> 3N^2 Hessian elements
158
- N = batch.pos.shape[0]
159
- forces = forces.reshape(-1)
160
- num_elements = forces.shape[0]
161
-
162
- def get_vjp(v):
163
- return torch.autograd.grad(
164
- outputs=-1 * forces,
165
- inputs=batch.pos,
166
- grad_outputs=v,
167
- retain_graph=True,
168
- create_graph=False,
169
- allow_unused=False,
170
- )
171
-
172
- I_N = torch.eye(num_elements, device=forces.device)
173
- hessian = torch.vmap(get_vjp, in_dims=0, out_dims=0, chunk_size=None)(I_N)[
174
- 0
175
- ]
176
- hessian = hessian.view(N * 3, N * 3)
177
- self.results["hessian"] = hessian.detach().cpu().numpy()
178
-
179
- if "eigen" in properties:
180
- eigenvalues, eigenvectors = torch.linalg.eigh(hessian)
181
- smallest_eigenvals = eigenvalues[:2]
182
- smallest_eigenvecs = eigenvectors[:, :2]
183
- self.results["eigenvalues"] = smallest_eigenvals.detach().cpu().numpy()
184
- self.results["eigenvectors"] = (
185
- smallest_eigenvecs.T.view(2, N, 3).detach().cpu().numpy()
186
- )
187
-
188
- def get_hessian_autodiff(self, atoms):
189
- """
190
- Get the Hessian matrix for the given atoms via autodiff (on the fly Hessian).
191
- """
192
- self.calculate(atoms, properties=["energy", "forces", "hessian"])
193
- return self.results["hessian"]
194
-
195
- def get_eigen_autodiff(self, atoms):
196
- """
197
- Get the eigenvalues and eigenvectors for the given atoms via autodiff (on the fly eigenvalues).
198
- """
199
- self.calculate(atoms, properties=["energy", "forces", "hessian", "eigen"])
200
- return self.results["eigenvalues"], self.results["eigenvectors"]
201
-
202
- # Andreas: really not sure if this is correct
203
- def get_vibrational_analysis(
204
- self, atoms, filter_threshold=1.0, preserve_imaginary=True
205
- ):
206
- """
207
- Compute vibrational modes using mass-weighted Hessian.
208
-
209
- Args:
210
- atoms: ASE Atoms object
211
- filter_threshold: Threshold for filtering small frequencies (cm^-1).
212
- Set to 0.0 to keep all modes.
213
- preserve_imaginary: If True, preserves imaginary frequencies regardless of threshold.
214
- Important for transition state analysis.
215
-
216
- Returns:
217
- dict: Dictionary containing vibrational analysis results:
218
- - frequencies: vibrational frequencies in cm^-1 (negative = imaginary)
219
- - normal_modes: normal mode eigenvectors (mass-weighted)
220
- - reduced_masses: reduced masses for each mode
221
- - force_constants: force constants for each mode
222
- """
223
- # Get the Hessian matrix
224
- self.calculate(atoms, properties=["energy", "forces", "hessian"])
225
- hessian = self.results["hessian"]
226
-
227
- # Get atomic masses in atomic units
228
- masses = atoms.get_masses() # in amu
229
- masses_au = masses * 1822.888486 # Convert amu to atomic units
230
-
231
- # Create mass matrix (diagonal matrix with masses repeated 3 times for x,y,z)
232
- N = len(atoms)
233
- mass_matrix = np.zeros((3 * N, 3 * N))
234
- for i in range(N):
235
- mass_matrix[3 * i : 3 * i + 3, 3 * i : 3 * i + 3] = masses_au[i] * np.eye(3)
236
-
237
- # Create mass-weighted Hessian: H_mw = M^(-1/2) * H * M^(-1/2)
238
- mass_matrix_sqrt = np.sqrt(mass_matrix)
239
- mass_matrix_inv_sqrt = np.linalg.inv(mass_matrix_sqrt)
240
-
241
- # Mass-weighted Hessian
242
- hessian_mw = mass_matrix_inv_sqrt @ hessian @ mass_matrix_inv_sqrt
243
-
244
- # Solve eigenvalue problem for mass-weighted Hessian
245
- eigenvalues, eigenvectors = np.linalg.eigh(hessian_mw)
246
-
247
- # Convert eigenvalues to frequencies
248
- # ω = sqrt(λ) where λ are eigenvalues of mass-weighted Hessian
249
- # Convert from atomic units to cm^-1
250
- # 1 Hartree = 219474.631 cm^-1
251
- # 1 atomic unit of frequency = sqrt(Hartree/amu) = 5140.487 cm^-1
252
- frequencies_cm = np.sqrt(np.abs(eigenvalues)) * 5140.487
253
-
254
- # Handle negative eigenvalues (imaginary frequencies)
255
- frequencies_cm = np.where(eigenvalues < 0, -frequencies_cm, frequencies_cm)
256
-
257
- # Convert eigenvectors back to Cartesian coordinates
258
- # Normal modes in Cartesian coordinates: Q = M^(-1/2) * q
259
- normal_modes_cart = mass_matrix_inv_sqrt @ eigenvectors
260
-
261
- # Calculate reduced masses for each mode
262
- # μ = 1 / (q^T * q) where q are mass-weighted normal modes
263
- reduced_masses = 1.0 / np.sum(eigenvectors**2, axis=0)
264
-
265
- # Calculate force constants
266
- # k = μ * ω^2
267
- force_constants = reduced_masses * (frequencies_cm / 5140.487) ** 2
268
-
269
- # Filter out translational and rotational modes
270
- # For transition state analysis, preserve imaginary frequencies (negative eigenvalues)
271
- if filter_threshold > 0.0:
272
- if preserve_imaginary:
273
- # Keep imaginary frequencies (negative) and real frequencies above threshold
274
- vibrational_mask = (frequencies_cm < 0) | (
275
- frequencies_cm > filter_threshold
276
- )
277
- else:
278
- # Traditional filtering by absolute value
279
- vibrational_mask = np.abs(frequencies_cm) > filter_threshold
280
- else:
281
- # Keep all modes
282
- vibrational_mask = np.ones(len(frequencies_cm), dtype=bool)
283
-
284
- print(
285
- f"Masked {np.sum(~vibrational_mask)} modes (filter_threshold={filter_threshold}, preserve_imaginary={preserve_imaginary})"
286
- )
287
-
288
- return {
289
- "frequencies": frequencies_cm[vibrational_mask],
290
- "normal_modes": normal_modes_cart[:, vibrational_mask],
291
- "reduced_masses": reduced_masses[vibrational_mask],
292
- "force_constants": force_constants[vibrational_mask],
293
- "all_frequencies": frequencies_cm,
294
- "all_normal_modes": normal_modes_cart,
295
- "all_reduced_masses": reduced_masses,
296
- "all_force_constants": force_constants,
297
- "vibrational_mask": vibrational_mask,
298
- }
299
-
300
- def analyze_stationary_point(self, atoms):
301
- """
302
- Analyze whether the structure is a minimum, transition state, or higher-order saddle point.
303
-
304
- Args:
305
- atoms: ASE Atoms object
306
-
307
- Returns:
308
- dict: Analysis results containing:
309
- - point_type: str ("minimum", "transition_state", "higher_order_saddle")
310
- - n_imaginary: int (number of imaginary frequencies)
311
- - frequencies: array of all frequencies
312
- - imaginary_frequencies: array of imaginary frequencies only
313
- """
314
- vib_results = self.get_vibrational_analysis(
315
- atoms, filter_threshold=1.0, preserve_imaginary=True
316
- )
317
- frequencies = vib_results["frequencies"]
318
-
319
- imaginary_freqs = frequencies[frequencies < 0]
320
- n_imaginary = len(imaginary_freqs)
321
-
322
- if n_imaginary == 0:
323
- point_type = "minimum"
324
- elif n_imaginary == 1:
325
- point_type = "transition_state"
326
- else:
327
- point_type = "higher_order_saddle"
328
-
329
- return {
330
- "point_type": point_type,
331
- "n_imaginary": n_imaginary,
332
- "frequencies": frequencies,
333
- "imaginary_frequencies": imaginary_freqs,
334
- "all_results": vib_results,
335
- }
336
-
337
-
338
- if __name__ == "__main__":
339
- import os
340
- from ase.vibrations import Vibrations
341
-
342
- # Create a simple water molecule for testing
343
- atoms = Atoms(
344
- symbols="H2O",
345
- positions=[
346
- [0.0, 0.0, 0.0], # O
347
- [0.0, 0.757, 0.587], # H
348
- [0.0, -0.757, 0.587], # H
349
- ],
350
- )
351
-
352
- # Initialize calculator with default checkpoint path
353
- project_root = os.path.dirname(os.path.dirname(__file__))
354
- checkpoint_path = os.path.join(project_root, "ckpt/eqv2.ckpt")
355
-
356
- if not os.path.exists(checkpoint_path):
357
- print(f"Checkpoint not found at {checkpoint_path}")
358
- print("Please provide a valid checkpoint path")
359
- exit()
360
-
361
- calculator = EquiformerASECalculator(
362
- checkpoint_path=checkpoint_path, project_root=project_root
363
- )
364
-
365
- # Attach calculator to atoms
366
- # atoms.set_calculator(calculator)
367
- atoms.calc = calculator
368
-
369
- # Calculate energy and forces
370
- energy = atoms.get_potential_energy()
371
- forces = atoms.get_forces()
372
-
373
- print(f"Energy: {energy:.6f} eV")
374
- print(f"Forces shape: {forces.shape}")
375
- print(f"Forces:\n{forces}")
376
-
377
- # To get hessian, we need to explicitly calculate it through the calculator
378
- calculator.calculate(atoms, properties=["energy", "forces", "hessian"])
379
- hessian = calculator.results["hessian"]
380
- print(f"Hessian shape: {hessian.shape}")
381
-
382
- eigenvalues, eigenvectors = calculator.get_eigen_autodiff(atoms)
383
- print(f"Eigenvalues: {eigenvalues}")
384
- print(f"Eigenvectors: {eigenvectors.shape}")
385
-
386
- # Compute vibrational analysis for transition state analysis
387
- print("\n=== Vibrational Analysis (Transition State Compatible) ===")
388
- vib_results = calculator.get_vibrational_analysis(atoms, preserve_imaginary=True)
389
-
390
- print(f"Number of vibrational modes: {len(vib_results['frequencies'])}")
391
- print(f"Vibrational frequencies (cm^-1):")
392
- imaginary_count = 0
393
- for i, freq in enumerate(vib_results["frequencies"]):
394
- if freq < 0:
395
- print(f" Mode {i+1}: {freq:.2f} (i)")
396
- imaginary_count += 1
397
- else:
398
- print(f" Mode {i+1}: {freq:.2f}")
399
-
400
- print(f"\nNumber of imaginary frequencies: {imaginary_count}")
401
- if imaginary_count == 1:
402
- print("This appears to be a transition state (1 imaginary frequency)")
403
- elif imaginary_count == 0:
404
- print("This appears to be a minimum (0 imaginary frequencies)")
405
- else:
406
- print(f"This has {imaginary_count} imaginary frequencies")
407
-
408
- # Compare with ASE's Vibrations class
409
- print("\n" + "=" * 40)
410
- print("Comparison with ASE's Vibrations class")
411
- vib = Vibrations(atoms)
412
- vib.run()
413
- vib.summary()
414
- vib.clean()