|
6 | 6 | import torch |
7 | 7 | from ase import Atoms |
8 | 8 | from metatomic.torch import ModelOutput |
9 | | -from metatomic_ase import MetatomicCalculator, SymmetrizedCalculator |
| 9 | +from metatomic_ase import ( |
| 10 | + MetatomicCalculator, |
| 11 | + SymmetrizedCalculator, |
| 12 | +) |
| 13 | +from metatomic_ase._calculator import _full_3x3_to_voigt_6_stress |
10 | 14 | from packaging.version import Version |
11 | 15 |
|
12 | 16 | from ._models import ( |
|
40 | 44 | } |
41 | 45 |
|
42 | 46 |
|
| 47 | +def _stress_ensemble_to_voigt(stress_ensemble: np.ndarray) -> np.ndarray: |
| 48 | + """Convert a [3, 3, n_ensemble] stress ensemble to Voigt [6, n_ensemble].""" |
| 49 | + n_ensemble = stress_ensemble.shape[2] |
| 50 | + return np.array( |
| 51 | + [ |
| 52 | + _full_3x3_to_voigt_6_stress(stress_ensemble[:, :, i]) |
| 53 | + for i in range(n_ensemble) |
| 54 | + ] |
| 55 | + ).T |
| 56 | + |
| 57 | + |
43 | 58 | class UPETCalculator(ase.calculators.calculator.Calculator): |
44 | 59 | """ |
45 | 60 | ASE Calculator for universal MLIPs based on the PET architecture. |
@@ -174,6 +189,24 @@ def __init__( |
174 | 189 | loaded_model._capabilities.dtype = DTYPE_TO_STR[dtype] |
175 | 190 | loaded_model = loaded_model.to(dtype=dtype, device=device) |
176 | 191 |
|
| 192 | + self._non_conservative = non_conservative |
| 193 | + selected_variant = None if variants is None else variants.get("energy") |
| 194 | + variant_postfix = f"/{selected_variant}" if selected_variant else "" |
| 195 | + self._variant_postfix = variant_postfix |
| 196 | + # resolved here from the model's own outputs (same variant convention as the |
| 197 | + # non-conservative keys above) so that no private MetatomicCalculator |
| 198 | + # attribute is needed to request the ensemble |
| 199 | + energy_ensemble_key = "energy_ensemble" + variant_postfix |
| 200 | + self._energy_ensemble_key: Optional[str] = ( |
| 201 | + energy_ensemble_key if energy_ensemble_key in model_outputs else None |
| 202 | + ) |
| 203 | + # cache of the last conservative forces/stress ensemble computation, as |
| 204 | + # (atoms, forces_ensemble, stress_ensemble); avoids recomputing the |
| 205 | + # expensive Jacobian pass when called again for the same atoms. |
| 206 | + self._uq_cache: Optional[ |
| 207 | + Tuple[Atoms, Optional[np.ndarray], Optional[np.ndarray]] |
| 208 | + ] = None |
| 209 | + |
177 | 210 | self.calculator = MetatomicCalculator( |
178 | 211 | loaded_model, |
179 | 212 | extensions_directory=None, |
@@ -274,9 +307,232 @@ def get_energy_ensemble( |
274 | 307 | :param per_atom: Whether to return the energies per atom. |
275 | 308 | :return: Energy uncertainty in numpy.ndarray format. |
276 | 309 | """ |
277 | | - key = self.calculator._energy_uq_key.replace("_uncertainty", "_ensemble") |
| 310 | + key = self._energy_ensemble_key |
| 311 | + if key is None: |
| 312 | + raise NotImplementedError( |
| 313 | + "Energy ensemble is not available for the selected model. For " |
| 314 | + "uncertainty estimates, please use one of the following models: " |
| 315 | + f"{UPET_UQ_SUPPORTED_MODELS}" |
| 316 | + ) |
278 | 317 | return self._run_uq(atoms=atoms, per_atom=per_atom, key=key) |
279 | 318 |
|
| 319 | + def _run_forces_stress_uq( |
| 320 | + self, |
| 321 | + atoms: Optional[Atoms] = None, |
| 322 | + compute_forces: bool = True, |
| 323 | + compute_stress: bool = True, |
| 324 | + ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray]]: |
| 325 | + """ |
| 326 | + Compute force and/or stress ensembles via gradients of the energy ensemble. |
| 327 | +
|
| 328 | + Returns a tuple (forces_ensemble, stress_ensemble) where each is None if not |
| 329 | + requested. Forces ensemble has shape [n_atoms, 3, n_ensemble], stress ensemble |
| 330 | + has shape [3, 3, n_ensemble]. |
| 331 | + """ |
| 332 | + if not self.calculator._calculate_uncertainty: |
| 333 | + raise NotImplementedError( |
| 334 | + "Forces/stress uncertainty and ensemble are not available for the " |
| 335 | + "selected model. For uncertainty estimates, please use one of the " |
| 336 | + f"following models: {UPET_UQ_SUPPORTED_MODELS}" |
| 337 | + ) |
| 338 | + |
| 339 | + if not compute_forces and not compute_stress: |
| 340 | + raise ValueError( |
| 341 | + "At least one of compute_forces or compute_stress must be True." |
| 342 | + ) |
| 343 | + |
| 344 | + if atoms is None: |
| 345 | + if self.atoms is None: |
| 346 | + raise ValueError( |
| 347 | + "No `atoms` provided and no previously calculated atoms found." |
| 348 | + ) |
| 349 | + else: |
| 350 | + atoms = self.atoms |
| 351 | + |
| 352 | + cached = self._uq_cache |
| 353 | + if cached is not None and cached[0] == atoms: |
| 354 | + cached_forces, cached_stress = cached[1], cached[2] |
| 355 | + if (not compute_forces or cached_forces is not None) and ( |
| 356 | + not compute_stress or cached_stress is not None |
| 357 | + ): |
| 358 | + return ( |
| 359 | + cached_forces if compute_forces else None, |
| 360 | + cached_stress if compute_stress else None, |
| 361 | + ) |
| 362 | + |
| 363 | + calc = self.calculator |
| 364 | + # Unwrap SymmetrizedCalculator if present |
| 365 | + if isinstance(calc, SymmetrizedCalculator): |
| 366 | + calc = calc._calculator |
| 367 | + |
| 368 | + ensemble_key = self._energy_ensemble_key |
| 369 | + if ensemble_key is None: |
| 370 | + raise NotImplementedError( |
| 371 | + "Energy ensemble is not available for the selected model. For " |
| 372 | + "uncertainty estimates, please use one of the following models: " |
| 373 | + f"{UPET_UQ_SUPPORTED_MODELS}" |
| 374 | + ) |
| 375 | + |
| 376 | + explicit_gradients = [] |
| 377 | + if compute_forces: |
| 378 | + explicit_gradients.append("positions") |
| 379 | + if compute_stress: |
| 380 | + explicit_gradients.append("strain") |
| 381 | + |
| 382 | + outputs_request = { |
| 383 | + ensemble_key: ModelOutput( |
| 384 | + unit="eV", sample_kind="system", explicit_gradients=explicit_gradients |
| 385 | + ) |
| 386 | + } |
| 387 | + if calc._energy_key is not None: |
| 388 | + # some models require the base "energy" output to be requested |
| 389 | + # alongside the ensemble; this is cheap to compute (it is an |
| 390 | + # intermediate step of the ensemble itself) and its value is unused here |
| 391 | + outputs_request[calc._energy_key] = ModelOutput( |
| 392 | + unit="eV", sample_kind="system" |
| 393 | + ) |
| 394 | + |
| 395 | + block = calc.run_model(atoms, outputs_request)[ensemble_key].block() |
| 396 | + |
| 397 | + forces_ensemble = None |
| 398 | + stress_ensemble = None |
| 399 | + |
| 400 | + if compute_forces: |
| 401 | + # gradient shape: [n_atoms, 3, n_ensemble] |
| 402 | + forces_ensemble = ( |
| 403 | + -block.gradient("positions").values.detach().cpu().double().numpy() |
| 404 | + ) |
| 405 | + # remove the mean over atoms for each ensemble member to impose |
| 406 | + # translational invariance |
| 407 | + forces_ensemble = forces_ensemble - np.mean( |
| 408 | + forces_ensemble, axis=0, keepdims=True |
| 409 | + ) |
| 410 | + |
| 411 | + if compute_stress: |
| 412 | + # gradient shape: [1, 3, 3, n_ensemble] (single system) |
| 413 | + # -> [3, 3, n_ensemble] |
| 414 | + stress_ensemble = ( |
| 415 | + block.gradient("strain").values.detach().cpu().double().numpy()[0] |
| 416 | + / atoms.cell.volume |
| 417 | + ) |
| 418 | + |
| 419 | + # keep whichever of forces/stress was already cached for these atoms but |
| 420 | + # not recomputed here, instead of discarding it |
| 421 | + if cached is not None and cached[0] == atoms: |
| 422 | + forces_ensemble = forces_ensemble if compute_forces else cached[1] |
| 423 | + stress_ensemble = stress_ensemble if compute_stress else cached[2] |
| 424 | + self._uq_cache = (atoms.copy(), forces_ensemble, stress_ensemble) |
| 425 | + return ( |
| 426 | + forces_ensemble if compute_forces else None, |
| 427 | + stress_ensemble if compute_stress else None, |
| 428 | + ) |
| 429 | + |
| 430 | + def get_forces_ensemble(self, atoms: Optional[Atoms] = None) -> np.ndarray: |
| 431 | + """ |
| 432 | + Get the ensemble of forces for a given :py:class:`ase.Atoms` object. |
| 433 | +
|
| 434 | + Forces are computed as the (negative) derivative of the energy ensemble |
| 435 | + with respect to positions (conservative forces). |
| 436 | +
|
| 437 | + :param atoms: ASE atoms object. If ``None``, the last calculated atoms will be |
| 438 | + used. |
| 439 | + :return: Forces ensemble as numpy.ndarray with shape [n_atoms, 3, n_ensemble], |
| 440 | + in eV/Angstrom. |
| 441 | + """ |
| 442 | + forces_ensemble, _ = self._run_forces_stress_uq( |
| 443 | + atoms=atoms, compute_forces=True, compute_stress=False |
| 444 | + ) |
| 445 | + return forces_ensemble |
| 446 | + |
| 447 | + def get_forces_uncertainty(self, atoms: Optional[Atoms] = None) -> np.ndarray: |
| 448 | + """ |
| 449 | + Get the forces uncertainty for a given :py:class:`ase.Atoms` object. |
| 450 | +
|
| 451 | + Uncertainty is computed as the standard deviation of the conservative |
| 452 | + forces ensemble (derived from the energy ensemble). |
| 453 | +
|
| 454 | + :param atoms: ASE atoms object. If ``None``, the last calculated atoms will be |
| 455 | + used. |
| 456 | + :return: Forces uncertainty as numpy.ndarray with shape [n_atoms, 3], |
| 457 | + in eV/Angstrom. |
| 458 | + """ |
| 459 | + forces_ensemble = self.get_forces_ensemble(atoms=atoms) |
| 460 | + return np.std(forces_ensemble, axis=2) |
| 461 | + |
| 462 | + def get_stress_ensemble( |
| 463 | + self, atoms: Optional[Atoms] = None, voigt: bool = True |
| 464 | + ) -> np.ndarray: |
| 465 | + """ |
| 466 | + Get the ensemble of stresses for a given :py:class:`ase.Atoms` object. |
| 467 | +
|
| 468 | + Stresses are computed as the derivative of the energy ensemble with |
| 469 | + respect to strain (conservative stresses). |
| 470 | +
|
| 471 | + :param atoms: ASE atoms object. If ``None``, the last calculated atoms will be |
| 472 | + used. |
| 473 | + :param voigt: If ``True`` (default), return stresses in Voigt notation with |
| 474 | + shape [6, n_ensemble] (xx, yy, zz, yz, xz, xy). If ``False``, return |
| 475 | + full 3x3 tensors with shape [3, 3, n_ensemble]. |
| 476 | + :return: Stress ensemble in eV/Angstrom^3. |
| 477 | + """ |
| 478 | + _, stress_ensemble = self._run_forces_stress_uq( |
| 479 | + atoms=atoms, compute_forces=False, compute_stress=True |
| 480 | + ) |
| 481 | + if voigt: |
| 482 | + assert stress_ensemble is not None # for mypy |
| 483 | + stress_ensemble = _stress_ensemble_to_voigt(stress_ensemble) |
| 484 | + return stress_ensemble |
| 485 | + |
| 486 | + def get_stress_uncertainty( |
| 487 | + self, atoms: Optional[Atoms] = None, voigt: bool = True |
| 488 | + ) -> np.ndarray: |
| 489 | + """ |
| 490 | + Get the stress uncertainty for a given :py:class:`ase.Atoms` object. |
| 491 | +
|
| 492 | + Uncertainty is computed as the standard deviation of the stress ensemble |
| 493 | + (conservative stresses derived from the energy ensemble). |
| 494 | +
|
| 495 | + :param atoms: ASE atoms object. If ``None``, the last calculated atoms will be |
| 496 | + used. |
| 497 | + :param voigt: If ``True`` (default), return uncertainty in Voigt notation with |
| 498 | + shape [6] (xx, yy, zz, yz, xz, xy). If ``False``, return full 3x3 tensor |
| 499 | + with shape [3, 3]. |
| 500 | + :return: Stress uncertainty in eV/Angstrom^3. |
| 501 | + """ |
| 502 | + stress_ensemble = self.get_stress_ensemble(atoms=atoms, voigt=voigt) |
| 503 | + return np.std(stress_ensemble, axis=-1) |
| 504 | + |
| 505 | + def get_forces_and_stress_ensemble( |
| 506 | + self, atoms: Optional[Atoms] = None, voigt: bool = True |
| 507 | + ) -> Tuple[np.ndarray, np.ndarray]: |
| 508 | + """ |
| 509 | + Get force and stress ensembles together (conservative method only). |
| 510 | +
|
| 511 | + Equivalent to calling :py:meth:`get_forces_ensemble` and |
| 512 | + :py:meth:`get_stress_ensemble` separately, but cheaper: both are |
| 513 | + derived from the same backward pass through the energy ensemble. |
| 514 | +
|
| 515 | + :param atoms: ASE atoms object. If ``None``, the last calculated atoms will be |
| 516 | + used. |
| 517 | + :param voigt: If ``True`` (default), return stress in Voigt notation with |
| 518 | + shape [6, n_ensemble]. If ``False``, return full 3x3 tensors with shape |
| 519 | + [3, 3, n_ensemble]. |
| 520 | + :return: Tuple ``(forces_ensemble, stress_ensemble)``. Forces ensemble has |
| 521 | + shape [n_atoms, 3, n_ensemble], in eV/Angstrom. |
| 522 | + """ |
| 523 | + if self._non_conservative: |
| 524 | + raise ValueError( |
| 525 | + "get_forces_and_stress_ensemble is not available when the " |
| 526 | + "calculator was initialized with non_conservative=True." |
| 527 | + ) |
| 528 | + forces_ensemble, stress_ensemble = self._run_forces_stress_uq( |
| 529 | + atoms=atoms, compute_forces=True, compute_stress=True |
| 530 | + ) |
| 531 | + if voigt: |
| 532 | + assert stress_ensemble is not None # for mypy |
| 533 | + stress_ensemble = _stress_ensemble_to_voigt(stress_ensemble) |
| 534 | + return forces_ensemble, stress_ensemble |
| 535 | + |
280 | 536 |
|
281 | 537 | # For PET-MAD-DOS predictions |
282 | 538 | ENERGY_INTERVAL = 0.05 # Interval of the energy grid for DOS |
|
0 commit comments