@@ -200,6 +200,10 @@ def __init__(
200200 self ._energy_ensemble_key : Optional [str ] = (
201201 energy_ensemble_key if energy_ensemble_key in model_outputs else None
202202 )
203+ self ._direct_ensemble_key_BASE = "mtt::aux::non_conservative_forces_ensemble"
204+ self ._direct_uncertainty_key_BASE = (
205+ "mtt::aux::non_conservative_forces_uncertainty"
206+ )
203207 # cache of the last conservative forces/stress ensemble computation, as
204208 # (atoms, forces_ensemble, stress_ensemble); avoids recomputing the
205209 # expensive Jacobian pass when called again for the same atoms.
@@ -427,36 +431,180 @@ def _run_forces_stress_uq(
427431 stress_ensemble if compute_stress else None ,
428432 )
429433
430- def get_forces_ensemble (self , atoms : Optional [Atoms ] = None ) -> np .ndarray :
434+ @property
435+ def _direct_ensemble_key (self ) -> str :
436+ return self ._direct_ensemble_key_BASE + self ._variant_postfix
437+
438+ @property
439+ def _direct_uncertainty_key (self ) -> str :
440+ return self ._direct_uncertainty_key_BASE + self ._variant_postfix
441+
442+ def _run_direct_forces_uq (self , atoms : Optional [Atoms ] = None ) -> np .ndarray :
431443 """
432- Get the ensemble of forces for a given :py:class:`ase.Atoms` object.
444+ Get the direct forces ensemble from the model.
445+
446+ :return: Forces ensemble as numpy.ndarray with shape [n_atoms, 3, n_ensemble].
447+ """
448+ if atoms is None :
449+ if self .atoms is None :
450+ raise ValueError (
451+ "No `atoms` provided and no previously calculated atoms found."
452+ )
453+ else :
454+ atoms = self .atoms
455+
456+ calc = self .calculator
457+ if isinstance (calc , SymmetrizedCalculator ):
458+ calc = calc ._calculator
459+
460+ model_outputs = calc ._model .capabilities ().outputs
461+ if self ._direct_ensemble_key not in model_outputs :
462+ raise NotImplementedError (
463+ f"Direct forces ensemble ({ self ._direct_ensemble_key } ) is not "
464+ "available for the selected model."
465+ )
466+
467+ outputs = calc .run_model (
468+ atoms ,
469+ outputs = {
470+ self ._direct_ensemble_key : ModelOutput (
471+ quantity = "force" , unit = "eV/Angstrom" , per_atom = True
472+ )
473+ },
474+ )
475+
476+ # shape: [n_atoms, 3 * n_ensemble] -> [n_atoms, 3, n_ensemble]
477+ values = (
478+ outputs [self ._direct_ensemble_key ].block ().values .detach ().cpu ().numpy ()
479+ )
480+ return values .reshape (len (atoms ), 3 , - 1 )
481+
482+ def _run_direct_forces_uncertainty (
483+ self , atoms : Optional [Atoms ] = None
484+ ) -> np .ndarray :
485+ """
486+ Get the built-in direct forces uncertainty from the model.
487+
488+ :return: Forces uncertainty as numpy.ndarray with shape [n_atoms, 3].
489+ """
490+ if atoms is None :
491+ if self .atoms is None :
492+ raise ValueError (
493+ "No `atoms` provided and no previously calculated atoms found."
494+ )
495+ else :
496+ atoms = self .atoms
433497
434- Forces are computed as the (negative) derivative of the energy ensemble
435- with respect to positions (conservative forces).
498+ calc = self .calculator
499+ if isinstance (calc , SymmetrizedCalculator ):
500+ calc = calc ._calculator
501+
502+ model_outputs = calc ._model .capabilities ().outputs
503+ if self ._direct_uncertainty_key not in model_outputs :
504+ raise NotImplementedError (
505+ f"Direct forces uncertainty ({ self ._direct_uncertainty_key } ) is not "
506+ "available for the selected model."
507+ )
508+
509+ outputs = calc .run_model (
510+ atoms ,
511+ outputs = {
512+ self ._direct_uncertainty_key : ModelOutput (
513+ quantity = "force" , unit = "eV/Angstrom" , per_atom = True
514+ )
515+ },
516+ )
517+
518+ # shape: [n_atoms, 3, 1] -> [n_atoms, 3]
519+ values = (
520+ outputs [self ._direct_uncertainty_key ].block ().values .detach ().cpu ().numpy ()
521+ )
522+ return values .squeeze (- 1 )
523+
524+ def _resolve_forces_method (self , method : Optional [str ]) -> str :
525+ """Resolve the default forces method based on the calculator mode."""
526+ if method is None :
527+ return "direct" if self ._non_conservative else "conservative"
528+ return method
529+
530+ def get_forces_ensemble (
531+ self , atoms : Optional [Atoms ] = None , method : Optional [str ] = None
532+ ) -> np .ndarray :
533+ """
534+ Get the ensemble of forces for a given :py:class:`ase.Atoms` object.
436535
437536 :param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
438537 used.
538+ :param method: Method to compute the forces ensemble. One of:
539+
540+ - ``"conservative"`` (default when ``non_conservative=False``): forces
541+ derived from the energy ensemble via automatic differentiation.
542+ - ``"direct"`` (default when ``non_conservative=True``): forces ensemble
543+ predicted directly by the model. When the calculator
544+ is in conservative mode (``non_conservative=False``), the ensemble is
545+ shifted so its mean matches the conservative forces.
546+
439547 :return: Forces ensemble as numpy.ndarray with shape [n_atoms, 3, n_ensemble],
440548 in eV/Angstrom.
441549 """
442- forces_ensemble , _ = self ._run_forces_stress_uq (
443- atoms = atoms , compute_forces = True , compute_stress = False
444- )
445- return forces_ensemble
550+ method = self ._resolve_forces_method (method )
551+
552+ if method == "conservative" :
553+ if self ._non_conservative :
554+ raise ValueError (
555+ "method='conservative' is not available when the calculator was "
556+ "initialized with non_conservative=True."
557+ )
558+ forces_ensemble , _ = self ._run_forces_stress_uq (
559+ atoms = atoms , compute_forces = True , compute_stress = False
560+ )
561+ return forces_ensemble
446562
447- def get_forces_uncertainty (self , atoms : Optional [Atoms ] = None ) -> np .ndarray :
563+ elif method == "direct" :
564+ direct_ensemble = self ._run_direct_forces_uq (atoms = atoms )
565+
566+ if not self ._non_conservative :
567+ # Shift ensemble so its mean matches conservative forces
568+ if atoms is None :
569+ atoms = self .atoms
570+ atoms .calc = self
571+ conservative_forces = atoms .get_forces () # [n_atoms, 3]
572+
573+ direct_mean = np .mean (direct_ensemble , axis = 2 ) # [n_atoms, 3]
574+ shift = conservative_forces - direct_mean # [n_atoms, 3]
575+ direct_ensemble = direct_ensemble + shift [:, :, np .newaxis ]
576+
577+ return direct_ensemble
578+
579+ else :
580+ raise ValueError (
581+ f"Unknown method '{ method } '. Must be one of: 'conservative', 'direct'."
582+ )
583+
584+ def get_forces_uncertainty (
585+ self , atoms : Optional [Atoms ] = None , method : Optional [str ] = None
586+ ) -> np .ndarray :
448587 """
449588 Get the forces uncertainty for a given :py:class:`ase.Atoms` object.
450589
451- Uncertainty is computed as the standard deviation of the conservative
452- forces ensemble (derived from the energy ensemble).
453-
454590 :param atoms: ASE atoms object. If ``None``, the last calculated atoms will be
455591 used.
592+ :param method: Method to compute the forces uncertainty. One of:
593+
594+ - ``"conservative"``: standard deviation of the conservative forces
595+ ensemble (derived from energy ensemble via autograd).
596+ - ``"direct"``: built-in uncertainty from the model
597+ (``mtt::aux::non_conservative_forces_uncertainty``).
598+
456599 :return: Forces uncertainty as numpy.ndarray with shape [n_atoms, 3],
457600 in eV/Angstrom.
458601 """
459- forces_ensemble = self .get_forces_ensemble (atoms = atoms )
602+ method = self ._resolve_forces_method (method )
603+
604+ if method == "direct" :
605+ return self ._run_direct_forces_uncertainty (atoms = atoms )
606+
607+ forces_ensemble = self .get_forces_ensemble (atoms = atoms , method = method )
460608 return np .std (forces_ensemble , axis = 2 )
461609
462610 def get_stress_ensemble (
0 commit comments