diff --git a/deepmd/jax/infer/deep_eval.py b/deepmd/jax/infer/deep_eval.py index ef65f93a38..8148f1e4fe 100644 --- a/deepmd/jax/infer/deep_eval.py +++ b/deepmd/jax/infer/deep_eval.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: LGPL-3.0-or-later import json +import warnings from collections.abc import ( Callable, ) @@ -256,7 +257,11 @@ def eval( - natoms x dim_aparam. Then all frames are assumed to be provided with the same aparam. - dim_aparam. Then all frames and atoms are provided with the same aparam. **kwargs - Other parameters + Other parameters. + charge_spin : array-like, optional + The per-frame charge/spin conditioning input. The array can be + of size nframes x dim_chg_spin, or dim_chg_spin to reuse the + same value for all frames. Returns ------- @@ -265,6 +270,7 @@ def eval( variables, and the values are the corresponding output arrays. """ # convert all of the input to numpy array + charge_spin = kwargs.pop("charge_spin", None) atom_types = np.array(atom_types, dtype=np.int32) coords = np.array(coords) if cells is not None: @@ -274,7 +280,7 @@ def eval( ) request_defs = self._get_request_defs(atomic) out = self._eval_func(self._eval_model, numb_test, natoms)( - coords, cells, atom_types, fparam, aparam, request_defs + coords, cells, atom_types, fparam, aparam, charge_spin, request_defs ) # ``AutoBatchSize.execute_all`` unwraps a single-output result out of # its tuple, which would make ``zip`` iterate over the array's frame @@ -373,6 +379,7 @@ def _eval_model( atom_types: np.ndarray, fparam: np.ndarray | None, aparam: np.ndarray | None, + charge_spin: np.ndarray | None, request_defs: list[OutputVariableDef], ) -> tuple[np.ndarray, ...]: model = self.dp @@ -406,17 +413,34 @@ def _eval_model( aparam_input = aparam.reshape(nframes, natoms, self.get_dim_aparam()) else: aparam_input = None + if charge_spin is not None and not self.has_chg_spin_ebd(): + warnings.warn( + "charge_spin was provided, but this model does not support " + "charge/spin conditioning. The provided charge_spin will be ignored.", + UserWarning, + stacklevel=2, + ) + charge_spin_input = ( + np.asarray(charge_spin, dtype=GLOBAL_NP_FLOAT_PRECISION) + if self.has_chg_spin_ebd() and charge_spin is not None + else None + ) do_atomic_virial = any( x.category == OutputVariableCategory.DERV_C_REDU for x in request_defs ) + model_kwargs = { + "box": to_jax_array(box_input), + "fparam": to_jax_array(fparam_input), + "aparam": to_jax_array(aparam_input), + "do_atomic_virial": do_atomic_virial, + } + if self.has_chg_spin_ebd(): + model_kwargs["charge_spin"] = to_jax_array(charge_spin_input) batch_output = model( to_jax_array(coord_input), to_jax_array(type_input), - box=to_jax_array(box_input), - fparam=to_jax_array(fparam_input), - aparam=to_jax_array(aparam_input), - do_atomic_virial=do_atomic_virial, + **model_kwargs, ) if isinstance(batch_output, tuple): batch_output = batch_output[0] @@ -483,3 +507,21 @@ def get_model(self) -> Any: def has_default_fparam(self) -> bool: """Check if the model has default frame parameters.""" return self.dp.has_default_fparam() + + def has_chg_spin_ebd(self) -> bool: + """Check if the model has charge spin embedding.""" + if hasattr(self.dp, "has_chg_spin_ebd"): + return self.dp.has_chg_spin_ebd() + return False + + def get_dim_chg_spin(self) -> int: + """Get the dimension of charge_spin input.""" + if hasattr(self.dp, "get_dim_chg_spin"): + return self.dp.get_dim_chg_spin() + return 0 + + def has_default_chg_spin(self) -> bool: + """Check if the model has default charge_spin values.""" + if hasattr(self.dp, "has_default_chg_spin"): + return self.dp.has_default_chg_spin() + return False diff --git a/deepmd/jax/jax2tf/make_model.py b/deepmd/jax/jax2tf/make_model.py index dba6d6946b..56fe0bbbd8 100644 --- a/deepmd/jax/jax2tf/make_model.py +++ b/deepmd/jax/jax2tf/make_model.py @@ -28,20 +28,36 @@ communicate_extended_output, ) - -def model_call_from_call_lower( - *, # enforce keyword-only arguments - call_lower: Callable[ +CallLower = ( + Callable[ [ tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor, tf.Tensor, - bool, + tf.Tensor, ], dict[str, tf.Tensor], - ], + ] + | Callable[ + [ + tf.Tensor, + tf.Tensor, + tf.Tensor, + tf.Tensor, + tf.Tensor, + tf.Tensor, + tf.Tensor, + ], + dict[str, tf.Tensor], + ] +) + + +def model_call_from_call_lower( + *, # enforce keyword-only arguments + call_lower: CallLower, rcut: float, sel: list[int], mixed_types: bool, @@ -51,6 +67,7 @@ def model_call_from_call_lower( box: tf.Tensor, fparam: tf.Tensor, aparam: tf.Tensor, + charge_spin: tf.Tensor | None = None, do_atomic_virial: bool = False, ) -> dict[str, tf.Tensor]: """Return model prediction from lower interface.""" @@ -79,13 +96,18 @@ def model_call_from_call_lower( distinguish_types=False, ) extended_coord = tf.reshape(extended_coord, [nframes, -1, 3]) + call_lower_kwargs = { + "fparam": fp, + "aparam": ap, + } + if charge_spin is not None: + call_lower_kwargs["charge_spin"] = charge_spin model_predict_lower = call_lower( extended_coord, extended_atype, nlist, mapping, - fparam=fp, - aparam=ap, + **call_lower_kwargs, ) model_predict = communicate_extended_output( model_predict_lower, diff --git a/deepmd/jax/jax2tf/serialization.py b/deepmd/jax/jax2tf/serialization.py index baf3c65b84..7690884d89 100644 --- a/deepmd/jax/jax2tf/serialization.py +++ b/deepmd/jax/jax2tf/serialization.py @@ -40,29 +40,95 @@ def deserialize_to_file(model_file: str, data: dict) -> None: _set_model_min_nbor_dist_from_data(model, data) model_def_script = data["model_def_script"] call_lower = model.call_common_lower + dim_chg_spin = model.get_dim_chg_spin() + has_chg_spin = dim_chg_spin > 0 tf_model = tf.Module() + def lower_input_signature() -> list[tf.TensorSpec]: + signature = [ + tf.TensorSpec([None, None, 3], tf.float64), + tf.TensorSpec([None, None], tf.int32), + tf.TensorSpec([None, None, None], tf.int64), + tf.TensorSpec([None, None], tf.int64), + tf.TensorSpec([None, model.get_dim_fparam()], tf.float64), + tf.TensorSpec([None, None, model.get_dim_aparam()], tf.float64), + ] + if has_chg_spin: + signature.append(tf.TensorSpec([None, dim_chg_spin], tf.float64)) + return signature + + def call_input_signature() -> list[tf.TensorSpec]: + signature = [ + tf.TensorSpec([None, None, 3], tf.float64), + tf.TensorSpec([None, None], tf.int32), + tf.TensorSpec([None, None, None], tf.float64), + tf.TensorSpec([None, model.get_dim_fparam()], tf.float64), + tf.TensorSpec([None, None, model.get_dim_aparam()], tf.float64), + ] + if has_chg_spin: + signature.append(tf.TensorSpec([None, dim_chg_spin], tf.float64)) + return signature + + def lower_args( + coord: tf.Tensor, + atype: tf.Tensor, + nlist: tf.Tensor, + mapping: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + charge_spin: tf.Tensor | None, + ) -> tuple[tf.Tensor, ...]: + args = (coord, atype, nlist, mapping, fparam, aparam) + if has_chg_spin: + assert charge_spin is not None + args = (*args, charge_spin) + return args + def exported_whether_do_atomic_virial( do_atomic_virial: bool, has_ghost_atoms: bool ) -> Callable: - def call_lower_with_fixed_do_atomic_virial( - coord: tf.Tensor, - atype: tf.Tensor, - nlist: tf.Tensor, - mapping: tf.Tensor, - fparam: tf.Tensor, - aparam: tf.Tensor, - ) -> dict[str, tf.Tensor]: - return call_lower( - coord, - atype, - nlist, - mapping, - fparam, - aparam, - do_atomic_virial=do_atomic_virial, - ) + if has_chg_spin: + + def call_lower_with_fixed_do_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + nlist: tf.Tensor, + mapping: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + charge_spin: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return call_lower( + coord, + atype, + nlist, + mapping, + fparam, + aparam, + do_atomic_virial=do_atomic_virial, + charge_spin=charge_spin, + ) + + else: + + def call_lower_with_fixed_do_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + nlist: tf.Tensor, + mapping: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return call_lower( + coord, + atype, + nlist, + mapping, + fparam, + aparam, + do_atomic_virial=do_atomic_virial, + ) # nghost >= 1 is assumed if there is ghost atoms. Other workarounds # do not work, such as nall; nloc + nghost - 1. @@ -74,80 +140,107 @@ def call_lower_with_fixed_do_atomic_virial( # semantics into TensorFlow. Its SavedModel graph is expected to # contain XlaCallModule ops; a graph made only of ordinary TF ops # means this path has accidentally fallen back to the TF2 exporter. + polymorphic_shapes = [ + f"(nf, nloc + {nghost}, 3)", + f"(nf, nloc + {nghost})", + f"(nf, nloc, {model.get_nnei()})", + f"(nf, nloc + {nghost})", + f"(nf, {model.get_dim_fparam()})", + f"(nf, nloc, {model.get_dim_aparam()})", + ] + if has_chg_spin: + polymorphic_shapes.append(f"(nf, {dim_chg_spin})") return jax2tf.convert( call_lower_with_fixed_do_atomic_virial, - polymorphic_shapes=[ - f"(nf, nloc + {nghost}, 3)", - f"(nf, nloc + {nghost})", - f"(nf, nloc, {model.get_nnei()})", - f"(nf, nloc + {nghost})", - f"(nf, {model.get_dim_fparam()})", - f"(nf, nloc, {model.get_dim_aparam()})", - ], + polymorphic_shapes=polymorphic_shapes, with_gradient=True, ) - @tf.function( - autograph=False, - input_signature=[ - tf.TensorSpec([None, None, 3], tf.float64), - tf.TensorSpec([None, None], tf.int32), - tf.TensorSpec([None, None, None], tf.int64), - tf.TensorSpec([None, None], tf.int64), - tf.TensorSpec([None, model.get_dim_fparam()], tf.float64), - tf.TensorSpec([None, None, model.get_dim_aparam()], tf.float64), - ], - ) - def call_lower_without_atomic_virial( + def dispatch_call_lower( + do_atomic_virial: bool, coord: tf.Tensor, atype: tf.Tensor, nlist: tf.Tensor, mapping: tf.Tensor, fparam: tf.Tensor, aparam: tf.Tensor, + charge_spin: tf.Tensor | None = None, ) -> dict[str, tf.Tensor]: nlist = format_nlist(coord, nlist, model.get_nnei(), model.get_rcut()) + args = lower_args(coord, atype, nlist, mapping, fparam, aparam, charge_spin) return tf.cond( tf.shape(coord)[1] == tf.shape(nlist)[1], lambda: exported_whether_do_atomic_virial( - do_atomic_virial=False, has_ghost_atoms=False - )(coord, atype, nlist, mapping, fparam, aparam), + do_atomic_virial=do_atomic_virial, has_ghost_atoms=False + )(*args), lambda: exported_whether_do_atomic_virial( - do_atomic_virial=False, has_ghost_atoms=True - )(coord, atype, nlist, mapping, fparam, aparam), + do_atomic_virial=do_atomic_virial, has_ghost_atoms=True + )(*args), ) + if has_chg_spin: + + @tf.function(autograph=False, input_signature=lower_input_signature()) + def call_lower_without_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + nlist: tf.Tensor, + mapping: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + charge_spin: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return dispatch_call_lower( + False, coord, atype, nlist, mapping, fparam, aparam, charge_spin + ) + + else: + + @tf.function(autograph=False, input_signature=lower_input_signature()) + def call_lower_without_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + nlist: tf.Tensor, + mapping: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return dispatch_call_lower( + False, coord, atype, nlist, mapping, fparam, aparam + ) + tf_model.call_lower = call_lower_without_atomic_virial - @tf.function( - autograph=False, - input_signature=[ - tf.TensorSpec([None, None, 3], tf.float64), - tf.TensorSpec([None, None], tf.int32), - tf.TensorSpec([None, None, None], tf.int64), - tf.TensorSpec([None, None], tf.int64), - tf.TensorSpec([None, model.get_dim_fparam()], tf.float64), - tf.TensorSpec([None, None, model.get_dim_aparam()], tf.float64), - ], - ) - def call_lower_with_atomic_virial( - coord: tf.Tensor, - atype: tf.Tensor, - nlist: tf.Tensor, - mapping: tf.Tensor, - fparam: tf.Tensor, - aparam: tf.Tensor, - ) -> dict[str, tf.Tensor]: - nlist = format_nlist(coord, nlist, model.get_nnei(), model.get_rcut()) - return tf.cond( - tf.shape(coord)[1] == tf.shape(nlist)[1], - lambda: exported_whether_do_atomic_virial( - do_atomic_virial=True, has_ghost_atoms=False - )(coord, atype, nlist, mapping, fparam, aparam), - lambda: exported_whether_do_atomic_virial( - do_atomic_virial=True, has_ghost_atoms=True - )(coord, atype, nlist, mapping, fparam, aparam), - ) + if has_chg_spin: + + @tf.function(autograph=False, input_signature=lower_input_signature()) + def call_lower_with_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + nlist: tf.Tensor, + mapping: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + charge_spin: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return dispatch_call_lower( + True, coord, atype, nlist, mapping, fparam, aparam, charge_spin + ) + + else: + + @tf.function(autograph=False, input_signature=lower_input_signature()) + def call_lower_with_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + nlist: tf.Tensor, + mapping: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return dispatch_call_lower( + True, coord, atype, nlist, mapping, fparam, aparam + ) tf_model.call_lower_atomic_virial = call_lower_with_atomic_virial @@ -163,6 +256,7 @@ def call( box: tf.Tensor | None = None, fparam: tf.Tensor | None = None, aparam: tf.Tensor | None = None, + charge_spin: tf.Tensor | None = None, ) -> dict[str, tf.Tensor]: return model_call_from_call_lower( call_lower=call_lower, @@ -175,54 +269,71 @@ def call( box=box, fparam=fparam, aparam=aparam, + charge_spin=charge_spin, do_atomic_virial=do_atomic_virial, ) return call - @tf.function( - autograph=True, - input_signature=[ - tf.TensorSpec([None, None, 3], tf.float64), - tf.TensorSpec([None, None], tf.int32), - tf.TensorSpec([None, None, None], tf.float64), - tf.TensorSpec([None, model.get_dim_fparam()], tf.float64), - tf.TensorSpec([None, None, model.get_dim_aparam()], tf.float64), - ], - ) - def call_with_atomic_virial( - coord: tf.Tensor, - atype: tf.Tensor, - box: tf.Tensor, - fparam: tf.Tensor, - aparam: tf.Tensor, - ) -> dict[str, tf.Tensor]: - return make_call_whether_do_atomic_virial(do_atomic_virial=True)( - coord, atype, box, fparam, aparam - ) + if has_chg_spin: + + @tf.function(autograph=True, input_signature=call_input_signature()) + def call_with_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + box: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + charge_spin: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return make_call_whether_do_atomic_virial(do_atomic_virial=True)( + coord, atype, box, fparam, aparam, charge_spin + ) + + else: + + @tf.function(autograph=True, input_signature=call_input_signature()) + def call_with_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + box: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return make_call_whether_do_atomic_virial(do_atomic_virial=True)( + coord, atype, box, fparam, aparam + ) tf_model.call_atomic_virial = call_with_atomic_virial - @tf.function( - autograph=True, - input_signature=[ - tf.TensorSpec([None, None, 3], tf.float64), - tf.TensorSpec([None, None], tf.int32), - tf.TensorSpec([None, None, None], tf.float64), - tf.TensorSpec([None, model.get_dim_fparam()], tf.float64), - tf.TensorSpec([None, None, model.get_dim_aparam()], tf.float64), - ], - ) - def call_without_atomic_virial( - coord: tf.Tensor, - atype: tf.Tensor, - box: tf.Tensor, - fparam: tf.Tensor, - aparam: tf.Tensor, - ) -> dict[str, tf.Tensor]: - return make_call_whether_do_atomic_virial(do_atomic_virial=False)( - coord, atype, box, fparam, aparam - ) + if has_chg_spin: + + @tf.function(autograph=True, input_signature=call_input_signature()) + def call_without_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + box: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + charge_spin: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return make_call_whether_do_atomic_virial(do_atomic_virial=False)( + coord, atype, box, fparam, aparam, charge_spin + ) + + else: + + @tf.function(autograph=True, input_signature=call_input_signature()) + def call_without_atomic_virial( + coord: tf.Tensor, + atype: tf.Tensor, + box: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + ) -> dict[str, tf.Tensor]: + return make_call_whether_do_atomic_virial(do_atomic_virial=False)( + coord, atype, box, fparam, aparam + ) tf_model.call = call_without_atomic_virial @@ -318,6 +429,33 @@ def get_default_fparam() -> tf.Tensor: tf_model.get_default_fparam = get_default_fparam + @tf.function + def has_chg_spin_ebd() -> tf.Tensor: + return tf.constant(model.has_chg_spin_ebd(), dtype=tf.bool) + + tf_model.has_chg_spin_ebd = has_chg_spin_ebd + + @tf.function + def get_dim_chg_spin() -> tf.Tensor: + return tf.constant(dim_chg_spin, dtype=tf.int64) + + tf_model.get_dim_chg_spin = get_dim_chg_spin + + @tf.function + def has_default_chg_spin() -> tf.Tensor: + return tf.constant(model.has_default_chg_spin(), dtype=tf.bool) + + tf_model.has_default_chg_spin = has_default_chg_spin + + @tf.function + def get_default_chg_spin() -> tf.Tensor: + default_chg_spin = model.get_default_chg_spin() + if default_chg_spin is None: + return tf.constant([], dtype=tf.double) + return tf.constant(default_chg_spin, dtype=tf.double) + + tf_model.get_default_chg_spin = get_default_chg_spin + # property models: persist the output name/dimension/intensiveness so # the evaluator can dispatch to DeepProperty and reshape the output. if hasattr(model, "get_var_name"): diff --git a/deepmd/jax/jax2tf/tfmodel.py b/deepmd/jax/jax2tf/tfmodel.py index 6b9a1fcab9..57efdb5c14 100644 --- a/deepmd/jax/jax2tf/tfmodel.py +++ b/deepmd/jax/jax2tf/tfmodel.py @@ -78,6 +78,26 @@ def __init__( self.default_fparam = self.model.get_default_fparam().numpy().tolist() else: self.default_fparam = None + self._has_chg_spin_ebd = ( + self.model.has_chg_spin_ebd().numpy().item() + if hasattr(self.model, "has_chg_spin_ebd") + else False + ) + self.dim_chg_spin = ( + self.model.get_dim_chg_spin().numpy().item() + if hasattr(self.model, "get_dim_chg_spin") + else 0 + ) + self._has_default_chg_spin = ( + self.model.has_default_chg_spin().numpy().item() + if hasattr(self.model, "has_default_chg_spin") + else False + ) + self.default_chg_spin = ( + self.model.get_default_chg_spin().numpy().tolist() + if hasattr(self.model, "get_default_chg_spin") + else None + ) # property models only (absent for other model types). if hasattr(self.model, "get_var_name"): self._var_name = self.model.get_var_name().numpy().decode() @@ -96,6 +116,7 @@ def __call__( fparam: jnp.ndarray | None = None, aparam: jnp.ndarray | None = None, do_atomic_virial: bool = False, + charge_spin: jnp.ndarray | None = None, ) -> Any: """Return model prediction. @@ -114,6 +135,8 @@ def __call__( atomic parameter. nf x nloc x nda do_atomic_virial If calculate the atomic virial. + charge_spin + The charge and spin conditioning input. shape: nf x dim_chg_spin Returns ------- @@ -122,7 +145,15 @@ def __call__( The keys are defined by the `ModelOutputDef`. """ - return self.call(coord, atype, box, fparam, aparam, do_atomic_virial) + return self.call( + coord, + atype, + box, + fparam, + aparam, + do_atomic_virial=do_atomic_virial, + charge_spin=charge_spin, + ) def call( self, @@ -132,6 +163,7 @@ def call( fparam: jnp.ndarray | None = None, aparam: jnp.ndarray | None = None, do_atomic_virial: bool = False, + charge_spin: jnp.ndarray | None = None, ) -> dict[str, jnp.ndarray]: """Return model prediction. @@ -150,6 +182,8 @@ def call( atomic parameter. nf x nloc x nda do_atomic_virial If calculate the atomic virial. + charge_spin + The charge and spin conditioning input. shape: nf x dim_chg_spin Returns ------- @@ -174,13 +208,11 @@ def call( (coord.shape[0], coord.shape[1], self.get_dim_aparam()), dtype=jnp.float64, ) - return call( - coord, - atype, - box, - fparam, - aparam, - ) + args = (coord, atype, box, fparam, aparam) + if self.get_dim_chg_spin() > 0: + charge_spin = self._make_charge_spin_input(coord.shape[0], charge_spin) + args = (*args, charge_spin) + return call(*args) def model_output_def(self) -> ModelOutputDef: return ModelOutputDef( @@ -230,14 +262,13 @@ def call_lower( (extended_coord.shape[0], nlist.shape[1], self.get_dim_aparam()), dtype=jnp.float64, ) - return call_lower( - extended_coord, - extended_atype, - nlist, - mapping, - fparam, - aparam, - ) + args = (extended_coord, extended_atype, nlist, mapping, fparam, aparam) + if self.get_dim_chg_spin() > 0: + charge_spin = self._make_charge_spin_input( + extended_coord.shape[0], charge_spin + ) + args = (*args, charge_spin) + return call_lower(*args) def get_type_map(self) -> list[str]: """Get the type map.""" @@ -377,6 +408,53 @@ def get_default_fparam(self) -> list[float] | None: """Get the default frame parameters.""" return self.default_fparam + def has_chg_spin_ebd(self) -> bool: + """Check if the model has charge spin embedding.""" + return self._has_chg_spin_ebd + + def get_dim_chg_spin(self) -> int: + """Get the dimension of charge_spin input.""" + return self.dim_chg_spin + + def has_default_chg_spin(self) -> bool: + """Check if the model has default charge_spin values.""" + return self._has_default_chg_spin + + def get_default_chg_spin(self) -> list[float] | None: + """Get the default charge_spin values.""" + return self.default_chg_spin + + def _make_charge_spin_input( + self, nframes: int, charge_spin: jnp.ndarray | None + ) -> jnp.ndarray: + dim_chg_spin = self.get_dim_chg_spin() + if dim_chg_spin == 0: + return jnp.empty((nframes, 0), dtype=jnp.float64) + if charge_spin is None: + if self.has_default_chg_spin(): + default_chg_spin = self.get_default_chg_spin() + assert default_chg_spin is not None + return jnp.tile( + jnp.asarray(default_chg_spin, dtype=jnp.float64).reshape(1, -1), + (nframes, 1), + ) + raise ValueError( + "charge_spin is required for this model but was not provided, " + "and the model has no default_chg_spin." + ) + charge_spin = jnp.asarray(charge_spin, dtype=jnp.float64) + if charge_spin.ndim == 1: + if charge_spin.size != dim_chg_spin: + raise ValueError("charge_spin must contain [charge, spin].") + charge_spin = charge_spin.reshape(1, dim_chg_spin) + elif charge_spin.ndim != 2 or charge_spin.shape[-1] != dim_chg_spin: + raise ValueError("charge_spin must have shape (nframes, 2).") + if charge_spin.shape[0] == 1 and nframes != 1: + return jnp.tile(charge_spin, (nframes, 1)) + if charge_spin.shape[0] != nframes: + raise ValueError("charge_spin first dimension must match nframes.") + return charge_spin + def get_var_name(self) -> str: """Get the name of the property (property models only).""" if self._var_name is None: diff --git a/source/api_c/include/deepmd.hpp b/source/api_c/include/deepmd.hpp index 1a68d0e227..69362165b8 100644 --- a/source/api_c/include/deepmd.hpp +++ b/source/api_c/include/deepmd.hpp @@ -868,28 +868,46 @@ namespace hpp { * @param[in] charge_spin The per-frame charge/spin values. * @param[in] dchgspin The model's charge/spin dimension (0 if unsupported). * @param[in] nframes The number of frames. - * @return charge_spin.data() if non-empty and valid, otherwise nullptr. + * @param[out] charge_spin_tiled Scratch storage used when one frame's + * charge_spin should be broadcast to all frames. + * @return charge_spin.data() or charge_spin_tiled.data() if non-empty and + * valid, otherwise nullptr. */ template inline const FPTYPE* validate_charge_spin( const std::vector& charge_spin, const int dchgspin, - const unsigned int nframes) { + const unsigned int nframes, + std::vector& charge_spin_tiled) { + charge_spin_tiled.clear(); if (charge_spin.empty()) { return nullptr; } if (dchgspin == 0) { - throw deepmd::hpp::deepmd_exception( - "charge_spin was provided, but this model does not support " - "charge/spin conditioning"); + std::cerr << "WARNING: charge_spin was provided, but this model does not " + "support charge/spin conditioning. The provided charge_spin " + "will be ignored." + << std::endl; + return nullptr; + } + const size_t dim = static_cast(dchgspin); + const size_t expected_size = static_cast(nframes) * dim; + if (charge_spin.size() == expected_size) { + return charge_spin.data(); } - if (charge_spin.size() != static_cast(nframes) * dchgspin) { - throw deepmd::hpp::deepmd_exception( - "the dim of charge_spin provided is not consistent with what the " - "model uses"); + if (charge_spin.size() == dim) { + charge_spin_tiled.resize(expected_size); + for (unsigned int ff = 0; ff < nframes; ++ff) { + std::copy(charge_spin.begin(), charge_spin.end(), + charge_spin_tiled.begin() + ff * dim); + } + return charge_spin_tiled.data(); } - return charge_spin.data(); + throw deepmd::hpp::deepmd_exception( + "the dim of charge_spin provided is not consistent with what the " + "model uses"); } + /** * @brief Neighbor list. **/ @@ -1193,9 +1211,10 @@ class DeepPot : public DeepBaseModel { * nframes x natoms x dim_aparam. * natoms x dim_aparam. Then all frames are assumed to be provided with the *same aparam. - * @param[in] charge_spin The per-frame charge/spin input. The array can be - *of size nframes x dim_chg_spin. Then all frames are assumed to be provided - *with the same charge_spin. Leave it empty to use the model's stored + * @param[in] charge_spin The charge/spin input. The array can be of size: + * nframes x dim_chg_spin. + * dim_chg_spin. Then all frames are assumed to be provided with the same + *charge_spin. Leave it empty to use the model's stored *default_chg_spin. * @warning Natoms should not be zero when computing multiple frames. **/ @@ -1232,8 +1251,9 @@ class DeepPot : public DeepBaseModel { const VALUETYPE* aparam__ = !aparam_.empty() ? &aparam_[0] : nullptr; // charge_spin routes to the version-3 C API; nullptr keeps version-2 so // non-charge_spin models still work against an older libdeepmd_c. - const VALUETYPE* charge_spin__ = - validate_charge_spin(charge_spin, dchgspin, nframes); + std::vector charge_spin_tiled_; + const VALUETYPE* charge_spin__ = validate_charge_spin( + charge_spin, dchgspin, nframes, charge_spin_tiled_); _DP_DeepPotCompute(dp, nframes, natoms, coord_, atype_, box_, fparam__, aparam__, charge_spin__, ener_, @@ -1261,9 +1281,10 @@ class DeepPot : public DeepBaseModel { * nframes x natoms x dim_aparam. * natoms x dim_aparam. Then all frames are assumed to be provided with the *same aparam. - * @param[in] charge_spin The per-frame charge/spin input. The array can be - *of size nframes x dim_chg_spin. Then all frames are assumed to be provided - *with the same charge_spin. Leave it empty to use the model's stored + * @param[in] charge_spin The charge/spin input. The array can be of size: + * nframes x dim_chg_spin. + * dim_chg_spin. Then all frames are assumed to be provided with the same + *charge_spin. Leave it empty to use the model's stored *default_chg_spin. * @warning Natoms should not be zero when computing multiple frames. **/ @@ -1305,8 +1326,9 @@ class DeepPot : public DeepBaseModel { tile_fparam_aparam(aparam_, nframes, natoms * daparam, aparam); const VALUETYPE* fparam__ = !fparam_.empty() ? &fparam_[0] : nullptr; const VALUETYPE* aparam__ = !aparam_.empty() ? &aparam_[0] : nullptr; - const VALUETYPE* charge_spin__ = - validate_charge_spin(charge_spin, dchgspin, nframes); + std::vector charge_spin_tiled_; + const VALUETYPE* charge_spin__ = validate_charge_spin( + charge_spin, dchgspin, nframes, charge_spin_tiled_); _DP_DeepPotCompute( dp, nframes, natoms, coord_, atype_, box_, fparam__, aparam__, @@ -1336,9 +1358,10 @@ class DeepPot : public DeepBaseModel { * nframes x natoms x dim_aparam. * natoms x dim_aparam. Then all frames are assumed to be provided with the *same aparam. - * @param[in] charge_spin The per-frame charge/spin input. The array can be - *of size nframes x dim_chg_spin. Then all frames are assumed to be provided - *with the same charge_spin. Leave it empty to use the model's stored + * @param[in] charge_spin The charge/spin input. The array can be of size: + * nframes x dim_chg_spin. + * dim_chg_spin. Then all frames are assumed to be provided with the same + *charge_spin. Leave it empty to use the model's stored *default_chg_spin. * @warning Natoms should not be zero when computing multiple frames. **/ @@ -1379,8 +1402,9 @@ class DeepPot : public DeepBaseModel { aparam); const VALUETYPE* fparam__ = !fparam_.empty() ? &fparam_[0] : nullptr; const VALUETYPE* aparam__ = !aparam_.empty() ? &aparam_[0] : nullptr; - const VALUETYPE* charge_spin__ = - validate_charge_spin(charge_spin, dchgspin, nframes); + std::vector charge_spin_tiled_; + const VALUETYPE* charge_spin__ = validate_charge_spin( + charge_spin, dchgspin, nframes, charge_spin_tiled_); _DP_DeepPotComputeNList(dp, nframes, natoms, coord_, atype_, box_, nghost, lmp_list.nl, ago, fparam__, @@ -1412,9 +1436,10 @@ class DeepPot : public DeepBaseModel { * nframes x natoms x dim_aparam. * natoms x dim_aparam. Then all frames are assumed to be provided with the *same aparam. - * @param[in] charge_spin The per-frame charge/spin input. The array can be - *of size nframes x dim_chg_spin. Then all frames are assumed to be provided - *with the same charge_spin. Leave it empty to use the model's stored + * @param[in] charge_spin The charge/spin input. The array can be of size: + * nframes x dim_chg_spin. + * dim_chg_spin. Then all frames are assumed to be provided with the same + *charge_spin. Leave it empty to use the model's stored *default_chg_spin. * @warning Natoms should not be zero when computing multiple frames. **/ @@ -1462,8 +1487,9 @@ class DeepPot : public DeepBaseModel { aparam); const VALUETYPE* fparam__ = !fparam_.empty() ? &fparam_[0] : nullptr; const VALUETYPE* aparam__ = !aparam_.empty() ? &aparam_[0] : nullptr; - const VALUETYPE* charge_spin__ = - validate_charge_spin(charge_spin, dchgspin, nframes); + std::vector charge_spin_tiled_; + const VALUETYPE* charge_spin__ = validate_charge_spin( + charge_spin, dchgspin, nframes, charge_spin_tiled_); _DP_DeepPotComputeNList(dp, nframes, natoms, coord_, atype_, box_, nghost, lmp_list.nl, ago, fparam__, @@ -2277,9 +2303,10 @@ class DeepPotModelDevi : public DeepBaseModelDevi { * nframes x natoms x dim_aparam. * natoms x dim_aparam. Then all frames are assumed to be provided with the *same aparam. - * @param[in] charge_spin The per-frame charge/spin input. The array can be - *of size nframes x dim_chg_spin. Then all frames are assumed to be provided - *with the same charge_spin. Leave it empty to use the model's stored + * @param[in] charge_spin The charge/spin input. The array can be of size: + * nframes x dim_chg_spin. + * dim_chg_spin. Then all frames are assumed to be provided with the same + *charge_spin. Leave it empty to use the model's stored *default_chg_spin. **/ template @@ -2293,7 +2320,6 @@ class DeepPotModelDevi : public DeepBaseModelDevi { const std::vector& fparam = std::vector(), const std::vector& aparam = std::vector(), const std::vector& charge_spin = std::vector()) { - // charge_spin is not supported via the C-API model-deviation path. unsigned int natoms = atype.size(); unsigned int nframes = 1; assert(natoms * 3 == coord.size()); @@ -2319,8 +2345,9 @@ class DeepPotModelDevi : public DeepBaseModelDevi { tile_fparam_aparam(aparam_, nframes, natoms * daparam, aparam); const VALUETYPE* fparam__ = !fparam_.empty() ? &fparam_[0] : nullptr; const VALUETYPE* aparam__ = !aparam_.empty() ? &aparam_[0] : nullptr; - const VALUETYPE* charge_spin__ = - validate_charge_spin(charge_spin, dchgspin, nframes); + std::vector charge_spin_tiled_; + const VALUETYPE* charge_spin__ = validate_charge_spin( + charge_spin, dchgspin, nframes, charge_spin_tiled_); _DP_DeepPotModelDeviCompute( dp, natoms, coord_, atype_, box_, fparam__, aparam__, charge_spin__, @@ -2364,9 +2391,10 @@ class DeepPotModelDevi : public DeepBaseModelDevi { * nframes x natoms x dim_aparam. * natoms x dim_aparam. Then all frames are assumed to be provided with the *same aparam. - * @param[in] charge_spin The per-frame charge/spin input. The array can be - *of size nframes x dim_chg_spin. Then all frames are assumed to be provided - *with the same charge_spin. Leave it empty to use the model's stored + * @param[in] charge_spin The charge/spin input. The array can be of size: + * nframes x dim_chg_spin. + * dim_chg_spin. Then all frames are assumed to be provided with the same + *charge_spin. Leave it empty to use the model's stored *default_chg_spin. **/ template @@ -2382,7 +2410,6 @@ class DeepPotModelDevi : public DeepBaseModelDevi { const std::vector& fparam = std::vector(), const std::vector& aparam = std::vector(), const std::vector& charge_spin = std::vector()) { - // charge_spin is not supported via the C-API model-deviation path. unsigned int natoms = atype.size(); unsigned int nframes = 1; assert(natoms * 3 == coord.size()); @@ -2412,8 +2439,9 @@ class DeepPotModelDevi : public DeepBaseModelDevi { tile_fparam_aparam(aparam_, nframes, natoms * daparam, aparam); const VALUETYPE* fparam__ = !fparam_.empty() ? &fparam_[0] : nullptr; const VALUETYPE* aparam__ = !aparam_.empty() ? &aparam_[0] : nullptr; - const VALUETYPE* charge_spin__ = - validate_charge_spin(charge_spin, dchgspin, nframes); + std::vector charge_spin_tiled_; + const VALUETYPE* charge_spin__ = validate_charge_spin( + charge_spin, dchgspin, nframes, charge_spin_tiled_); _DP_DeepPotModelDeviCompute( dp, natoms, coord_, atype_, box_, fparam__, aparam__, charge_spin__, @@ -2469,9 +2497,10 @@ class DeepPotModelDevi : public DeepBaseModelDevi { * nframes x natoms x dim_aparam. * natoms x dim_aparam. Then all frames are assumed to be provided with the *same aparam. - * @param[in] charge_spin The per-frame charge/spin input. The array can be - *of size nframes x dim_chg_spin. Then all frames are assumed to be provided - *with the same charge_spin. Leave it empty to use the model's stored + * @param[in] charge_spin The charge/spin input. The array can be of size: + * nframes x dim_chg_spin. + * dim_chg_spin. Then all frames are assumed to be provided with the same + *charge_spin. Leave it empty to use the model's stored *default_chg_spin. **/ template @@ -2516,8 +2545,9 @@ class DeepPotModelDevi : public DeepBaseModelDevi { aparam); const VALUETYPE* fparam__ = !fparam_.empty() ? &fparam_[0] : nullptr; const VALUETYPE* aparam__ = !aparam_.empty() ? &aparam_[0] : nullptr; - const VALUETYPE* charge_spin__ = - validate_charge_spin(charge_spin, dchgspin, nframes); + std::vector charge_spin_tiled_; + const VALUETYPE* charge_spin__ = validate_charge_spin( + charge_spin, dchgspin, nframes, charge_spin_tiled_); _DP_DeepPotModelDeviComputeNList( dp, natoms, coord_, atype_, box_, nghost, lmp_list.nl, ago, fparam__, @@ -2564,9 +2594,10 @@ class DeepPotModelDevi : public DeepBaseModelDevi { * nframes x natoms x dim_aparam. * natoms x dim_aparam. Then all frames are assumed to be provided with the *same aparam. - * @param[in] charge_spin The per-frame charge/spin input. The array can be - *of size nframes x dim_chg_spin. Then all frames are assumed to be provided - *with the same charge_spin. Leave it empty to use the model's stored + * @param[in] charge_spin The charge/spin input. The array can be of size: + * nframes x dim_chg_spin. + * dim_chg_spin. Then all frames are assumed to be provided with the same + *charge_spin. Leave it empty to use the model's stored *default_chg_spin. **/ template @@ -2617,8 +2648,9 @@ class DeepPotModelDevi : public DeepBaseModelDevi { aparam); const VALUETYPE* fparam__ = !fparam_.empty() ? &fparam_[0] : nullptr; const VALUETYPE* aparam__ = !aparam_.empty() ? &aparam_[0] : nullptr; - const VALUETYPE* charge_spin__ = - validate_charge_spin(charge_spin, dchgspin, nframes); + std::vector charge_spin_tiled_; + const VALUETYPE* charge_spin__ = validate_charge_spin( + charge_spin, dchgspin, nframes, charge_spin_tiled_); _DP_DeepPotModelDeviComputeNList( dp, natoms, coord_, atype_, box_, nghost, lmp_list.nl, ago, fparam__, diff --git a/source/api_c/tests/test_deepmd_exception.cc b/source/api_c/tests/test_deepmd_exception.cc index 96f6942a65..72dc1cd069 100644 --- a/source/api_c/tests/test_deepmd_exception.cc +++ b/source/api_c/tests/test_deepmd_exception.cc @@ -25,3 +25,55 @@ TEST(TestDeepmdException, deepmdexception_nofile) { ASSERT_THROW(deepmd::hpp::DeepPot("_no_such_file.pb"), deepmd::hpp::deepmd_exception); } + +TEST(TestChargeSpinValidation, exact_frame_values_use_input_storage) { + std::vector charge_spin = {1.0, 2.0, 3.0, 4.0}; + std::vector tiled; + + const double* result = + deepmd::hpp::validate_charge_spin(charge_spin, 2, 2, tiled); + + EXPECT_EQ(result, charge_spin.data()); + EXPECT_TRUE(tiled.empty()); +} + +TEST(TestChargeSpinValidation, single_frame_values_are_tiled) { + std::vector charge_spin = {1.0, 2.0}; + std::vector tiled; + + const double* result = + deepmd::hpp::validate_charge_spin(charge_spin, 2, 3, tiled); + + EXPECT_EQ(result, tiled.data()); + EXPECT_EQ(tiled, (std::vector{1.0, 2.0, 1.0, 2.0, 1.0, 2.0})); +} + +TEST(TestChargeSpinValidation, empty_values_use_model_default_path) { + std::vector charge_spin; + std::vector tiled; + + const double* result = + deepmd::hpp::validate_charge_spin(charge_spin, 2, 3, tiled); + + EXPECT_EQ(result, nullptr); + EXPECT_TRUE(tiled.empty()); +} + +TEST(TestChargeSpinValidation, ignores_values_for_unsupported_model) { + std::vector charge_spin = {1.0, 2.0}; + std::vector tiled; + + const double* result = + deepmd::hpp::validate_charge_spin(charge_spin, 0, 1, tiled); + + EXPECT_EQ(result, nullptr); + EXPECT_TRUE(tiled.empty()); +} + +TEST(TestChargeSpinValidation, rejects_invalid_size) { + std::vector charge_spin = {1.0, 2.0, 3.0}; + std::vector tiled; + + EXPECT_THROW(deepmd::hpp::validate_charge_spin(charge_spin, 2, 2, tiled), + deepmd::hpp::deepmd_exception); +} diff --git a/source/api_cc/include/DeepPotJAX.h b/source/api_cc/include/DeepPotJAX.h index 9469118968..e52c293474 100644 --- a/source/api_cc/include/DeepPotJAX.h +++ b/source/api_cc/include/DeepPotJAX.h @@ -102,6 +102,14 @@ class DeepPotJAX : public DeepPotBackend { assert(inited); return has_default_fparam_; }; + /** + * @brief Get dimension of charge/spin condition inputs. + * @return The dimension of charge/spin condition inputs. + **/ + int dim_chg_spin() const override { + assert(inited); + return dchgspin; + }; // forward to template class void computew(std::vector& ener, @@ -115,6 +123,18 @@ class DeepPotJAX : public DeepPotBackend { const std::vector& fparam, const std::vector& aparam, const bool atomic); + void computew(std::vector& ener, + std::vector& force, + std::vector& virial, + std::vector& atom_energy, + std::vector& atom_virial, + const std::vector& coord, + const std::vector& atype, + const std::vector& box, + const std::vector& fparam, + const std::vector& aparam, + const std::vector& charge_spin, + const bool atomic) override; void computew(std::vector& ener, std::vector& force, std::vector& virial, @@ -126,6 +146,18 @@ class DeepPotJAX : public DeepPotBackend { const std::vector& fparam, const std::vector& aparam, const bool atomic); + void computew(std::vector& ener, + std::vector& force, + std::vector& virial, + std::vector& atom_energy, + std::vector& atom_virial, + const std::vector& coord, + const std::vector& atype, + const std::vector& box, + const std::vector& fparam, + const std::vector& aparam, + const std::vector& charge_spin, + const bool atomic) override; void computew(std::vector& ener, std::vector& force, std::vector& virial, @@ -140,6 +172,21 @@ class DeepPotJAX : public DeepPotBackend { const std::vector& fparam, const std::vector& aparam, const bool atomic); + void computew(std::vector& ener, + std::vector& force, + std::vector& virial, + std::vector& atom_energy, + std::vector& atom_virial, + const std::vector& coord, + const std::vector& atype, + const std::vector& box, + const int nghost, + const InputNlist& inlist, + const int& ago, + const std::vector& fparam, + const std::vector& aparam, + const std::vector& charge_spin, + const bool atomic) override; void computew(std::vector& ener, std::vector& force, std::vector& virial, @@ -154,6 +201,21 @@ class DeepPotJAX : public DeepPotBackend { const std::vector& fparam, const std::vector& aparam, const bool atomic); + void computew(std::vector& ener, + std::vector& force, + std::vector& virial, + std::vector& atom_energy, + std::vector& atom_virial, + const std::vector& coord, + const std::vector& atype, + const std::vector& box, + const int nghost, + const InputNlist& inlist, + const int& ago, + const std::vector& fparam, + const std::vector& aparam, + const std::vector& charge_spin, + const bool atomic) override; void computew_mixed_type(std::vector& ener, std::vector& force, std::vector& virial, @@ -191,6 +253,12 @@ class DeepPotJAX : public DeepPotBackend { int dfparam; // the dimension of the atomic parameter int daparam; + // the dimension of charge/spin condition inputs + int dchgspin; + // has default charge/spin values + bool has_default_chg_spin_; + // default charge/spin values + std::vector default_chg_spin_; // type map std::string type_map; // sel @@ -257,6 +325,7 @@ class DeepPotJAX : public DeepPotBackend { const std::vector& box, const std::vector& fparam, const std::vector& aparam, + const std::vector& charge_spin, const bool atomic); /** @@ -299,6 +368,7 @@ class DeepPotJAX : public DeepPotBackend { const int& ago, const std::vector& fparam, const std::vector& aparam, + const std::vector& charge_spin, const bool atomic); }; } // namespace deepmd diff --git a/source/api_cc/src/DeepPotJAX.cc b/source/api_cc/src/DeepPotJAX.cc index 676c67a55b..27b3216015 100644 --- a/source/api_cc/src/DeepPotJAX.cc +++ b/source/api_cc/src/DeepPotJAX.cc @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -459,6 +460,54 @@ inline TFE_TensorHandle* add_input(TFE_Op* op, return handle; } +inline std::vector make_charge_spin_input( + const std::vector& charge_spin, + const int dchgspin, + const int nframes, + const std::vector& default_chg_spin) { + if (dchgspin == 0) { + if (!charge_spin.empty()) { + std::cerr << "WARNING: charge_spin was provided, but this model does " + "not support charge/spin conditioning. The provided " + "charge_spin will be ignored." + << std::endl; + } + if (!default_chg_spin.empty()) { + throw deepmd::deepmd_exception( + "default_chg_spin is stored, but this model does not support " + "charge/spin conditioning"); + } + return {}; + } + std::vector source; + if (!charge_spin.empty()) { + source = charge_spin; + } else if (!default_chg_spin.empty()) { + source = default_chg_spin; + } else { + throw deepmd::deepmd_exception( + "charge_spin is required for this model but was not provided, " + "and no default_chg_spin is stored in the model."); + } + const size_t dim = static_cast(dchgspin); + const size_t expected = static_cast(nframes) * dim; + if (source.size() == expected) { + return source; + } + if (source.size() == dim) { + std::vector result(expected); + for (int ff = 0; ff < nframes; ++ff) { + std::copy(source.begin(), source.end(), result.begin() + ff * dim); + } + return result; + } + throw deepmd::deepmd_exception( + "charge_spin has " + std::to_string(source.size()) + + " values but the model expects dim_chg_spin=" + std::to_string(dchgspin) + + " (per frame) or " + std::to_string(expected) + " (for " + + std::to_string(nframes) + " frames)."); +} + template inline void tensor_to_vector(std::vector& result, TFE_TensorHandle* retval, @@ -572,6 +621,28 @@ void deepmd::DeepPotJAX::init(const std::string& model, get_scalar(ctx, "get_dim_fparam", func_vector, device, status); daparam = get_scalar(ctx, "get_dim_aparam", func_vector, device, status); + try { + dchgspin = get_scalar(ctx, "get_dim_chg_spin", func_vector, device, + status); + } catch (tf_function_not_found& e) { + dchgspin = 0; + } + try { + has_default_chg_spin_ = get_scalar(ctx, "has_default_chg_spin", + func_vector, device, status); + } catch (tf_function_not_found& e) { + has_default_chg_spin_ = false; + } + if (dchgspin > 0 && has_default_chg_spin_) { + try { + default_chg_spin_ = get_vector(ctx, "get_default_chg_spin", + func_vector, device, status); + } catch (tf_function_not_found& e) { + default_chg_spin_.clear(); + } + } else { + default_chg_spin_.clear(); + } std::vector type_map_ = get_vector_string(ctx, "get_type_map", func_vector, device, status); // deepmd-kit stores type_map as a concatenated string, split by ' ' @@ -628,6 +699,7 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, const std::vector& box, const std::vector& fparam, const std::vector& aparam_, + const std::vector& charge_spin, const bool atomic) { std::vector coord, force, aparam, atom_energy, atom_virial; std::vector ener_double, force_double, virial_double, @@ -660,6 +732,8 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, std::vector box_double(box.begin(), box.end()); std::vector fparam_double(fparam.begin(), fparam.end()); std::vector aparam_double(aparam.begin(), aparam.end()); + std::vector charge_spin_double = + make_charge_spin_input(charge_spin, dchgspin, nframes, default_chg_spin_); TFE_Op* op; if (atomic) { @@ -669,15 +743,22 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, op = get_func_op(ctx, "call_without_atomic_virial", func_vector, device, status); } - std::vector input_list(5); - std::vector data_tensor(5); + const size_t num_inputs = 5 + (dchgspin > 0 ? 1 : 0); + std::vector input_list(num_inputs); + std::vector data_tensor(num_inputs); // coord std::vector coord_shape = {nframes, nloc_real, 3}; input_list[0] = add_input(op, coord_double, coord_shape, data_tensor[0], status); // atype + std::vector atype_input(static_cast(nframes) * nloc_real); + for (int ff = 0; ff < nframes; ++ff) { + std::copy(atype.begin(), atype.end(), + atype_input.begin() + static_cast(ff) * nloc_real); + } std::vector atype_shape = {nframes, nloc_real}; - input_list[1] = add_input(op, atype, atype_shape, data_tensor[1], status); + input_list[1] = + add_input(op, atype_input, atype_shape, data_tensor[1], status); // box int box_size = box_double.size() > 0 ? 3 : 0; std::vector box_shape = {nframes, box_size, box_size}; @@ -690,6 +771,11 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, std::vector aparam_shape = {nframes, nloc_real, daparam}; input_list[4] = add_input(op, aparam_double, aparam_shape, data_tensor[4], status); + if (dchgspin > 0) { + std::vector charge_spin_shape = {nframes, dchgspin}; + input_list[5] = add_input(op, charge_spin_double, charge_spin_shape, + data_tensor[5], status); + } // execute the function int nretvals = 6; TFE_TensorHandle* retvals[nretvals]; @@ -754,7 +840,7 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, fwd_map.size(), nall_real); // cleanup input_list, etc - for (size_t i = 0; i < 5; i++) { + for (size_t i = 0; i < input_list.size(); i++) { TFE_DeleteTensorHandle(input_list[i]); TF_DeleteTensor(data_tensor[i]); } @@ -778,6 +864,7 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, const int& ago, const std::vector& fparam, const std::vector& aparam_, + const std::vector& charge_spin, const bool atomic) { std::vector coord, force, aparam, atom_energy, atom_virial; std::vector ener_double, force_double, virial_double, @@ -808,6 +895,8 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, std::vector coord_double(coord.begin(), coord.end()); std::vector fparam_double(fparam.begin(), fparam.end()); std::vector aparam_double(aparam.begin(), aparam.end()); + std::vector charge_spin_double = + make_charge_spin_input(charge_spin, dchgspin, nframes, default_chg_spin_); int nall_model = nall_real; if (uses_xla_compilation_) { @@ -833,8 +922,9 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, op = get_func_op(ctx, "call_lower_without_atomic_virial", func_vector, device, status); } - std::vector input_list(6); - std::vector data_tensor(6); + const size_t num_inputs = 6 + (dchgspin > 0 ? 1 : 0); + std::vector input_list(num_inputs); + std::vector data_tensor(num_inputs); // coord std::vector coord_shape = {nframes, nall_model, 3}; input_list[0] = @@ -894,6 +984,11 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, std::vector aparam_shape = {nframes, nloc_real, daparam}; input_list[5] = add_input(op, aparam_double, aparam_shape, data_tensor[5], status); + if (dchgspin > 0) { + std::vector charge_spin_shape = {nframes, dchgspin}; + input_list[6] = add_input(op, charge_spin_double, charge_spin_shape, + data_tensor[6], status); + } // execute the function int nretvals = 6; TFE_TensorHandle* retvals[nretvals]; @@ -943,7 +1038,7 @@ void deepmd::DeepPotJAX::compute(std::vector& ener, fwd_map.size(), nall_real); // cleanup input_list, etc - for (size_t i = 0; i < 6; i++) { + for (size_t i = 0; i < input_list.size(); i++) { TFE_DeleteTensorHandle(input_list[i]); TF_DeleteTensor(data_tensor[i]); } @@ -967,6 +1062,7 @@ template void deepmd::DeepPotJAX::compute( const int& ago, const std::vector& fparam, const std::vector& aparam_, + const std::vector& charge_spin, const bool atomic); template void deepmd::DeepPotJAX::compute( @@ -983,6 +1079,7 @@ template void deepmd::DeepPotJAX::compute( const int& ago, const std::vector& fparam, const std::vector& aparam_, + const std::vector& charge_spin, const bool atomic); void deepmd::DeepPotJAX::get_type_map(std::string& type_map_) { @@ -1002,7 +1099,36 @@ void deepmd::DeepPotJAX::computew(std::vector& ener, const std::vector& aparam, const bool atomic) { compute(ener, force, virial, atom_energy, atom_virial, coord, atype, box, - fparam, aparam, atomic); + fparam, aparam, std::vector(), atomic); +} +void deepmd::DeepPotJAX::computew(std::vector& ener, + std::vector& force, + std::vector& virial, + std::vector& atom_energy, + std::vector& atom_virial, + const std::vector& coord, + const std::vector& atype, + const std::vector& box, + const std::vector& fparam, + const std::vector& aparam, + const std::vector& charge_spin, + const bool atomic) { + compute(ener, force, virial, atom_energy, atom_virial, coord, atype, box, + fparam, aparam, charge_spin, atomic); +} +void deepmd::DeepPotJAX::computew(std::vector& ener, + std::vector& force, + std::vector& virial, + std::vector& atom_energy, + std::vector& atom_virial, + const std::vector& coord, + const std::vector& atype, + const std::vector& box, + const std::vector& fparam, + const std::vector& aparam, + const bool atomic) { + compute(ener, force, virial, atom_energy, atom_virial, coord, atype, box, + fparam, aparam, std::vector(), atomic); } void deepmd::DeepPotJAX::computew(std::vector& ener, std::vector& force, @@ -1014,9 +1140,27 @@ void deepmd::DeepPotJAX::computew(std::vector& ener, const std::vector& box, const std::vector& fparam, const std::vector& aparam, + const std::vector& charge_spin, + const bool atomic) { + compute(ener, force, virial, atom_energy, atom_virial, coord, atype, box, + fparam, aparam, charge_spin, atomic); +} +void deepmd::DeepPotJAX::computew(std::vector& ener, + std::vector& force, + std::vector& virial, + std::vector& atom_energy, + std::vector& atom_virial, + const std::vector& coord, + const std::vector& atype, + const std::vector& box, + const int nghost, + const InputNlist& inlist, + const int& ago, + const std::vector& fparam, + const std::vector& aparam, const bool atomic) { compute(ener, force, virial, atom_energy, atom_virial, coord, atype, box, - fparam, aparam, atomic); + nghost, inlist, ago, fparam, aparam, std::vector(), atomic); } void deepmd::DeepPotJAX::computew(std::vector& ener, std::vector& force, @@ -1031,9 +1175,27 @@ void deepmd::DeepPotJAX::computew(std::vector& ener, const int& ago, const std::vector& fparam, const std::vector& aparam, + const std::vector& charge_spin, + const bool atomic) { + compute(ener, force, virial, atom_energy, atom_virial, coord, atype, box, + nghost, inlist, ago, fparam, aparam, charge_spin, atomic); +} +void deepmd::DeepPotJAX::computew(std::vector& ener, + std::vector& force, + std::vector& virial, + std::vector& atom_energy, + std::vector& atom_virial, + const std::vector& coord, + const std::vector& atype, + const std::vector& box, + const int nghost, + const InputNlist& inlist, + const int& ago, + const std::vector& fparam, + const std::vector& aparam, const bool atomic) { compute(ener, force, virial, atom_energy, atom_virial, coord, atype, box, - nghost, inlist, ago, fparam, aparam, atomic); + nghost, inlist, ago, fparam, aparam, std::vector(), atomic); } void deepmd::DeepPotJAX::computew(std::vector& ener, std::vector& force, @@ -1048,9 +1210,10 @@ void deepmd::DeepPotJAX::computew(std::vector& ener, const int& ago, const std::vector& fparam, const std::vector& aparam, + const std::vector& charge_spin, const bool atomic) { compute(ener, force, virial, atom_energy, atom_virial, coord, atype, box, - nghost, inlist, ago, fparam, aparam, atomic); + nghost, inlist, ago, fparam, aparam, charge_spin, atomic); } void deepmd::DeepPotJAX::computew_mixed_type(std::vector& ener, std::vector& force, diff --git a/source/api_cc/tests/test_deeppot_chg_spin_jax.cc b/source/api_cc/tests/test_deeppot_chg_spin_jax.cc new file mode 100644 index 0000000000..13c9a88840 --- /dev/null +++ b/source/api_cc/tests/test_deeppot_chg_spin_jax.cc @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later +// Test C++ inference for the JAX (.savedmodel) backend with charge_spin input. +// Model and reference data are generated by source/tests/infer/gen_chg_spin.py. +#include + +#include +#include +#include +#include +#include + +#include "DeepPot.h" +#include "expected_ref.h" +#include "neighbor_list.h" +#include "test_utils.h" + +namespace { +constexpr const char* kRefPath = "../../tests/infer/chg_spin.expected"; +constexpr const char* kModelPath = "../../tests/infer/chg_spin.savedmodel"; + +template +double tolerance() { + return std::is_same::value ? 1e-7 : 1e-4; +} +} // namespace + +template +class TestInferDeepPotChgSpinJAX : public ::testing::Test { + protected: + std::vector coord = {12.83, 2.56, 2.18, 12.09, 2.87, 2.74, + 0.25, 3.32, 1.68, 3.36, 3.00, 1.81, + 3.51, 2.51, 2.60, 4.27, 3.22, 1.56}; + std::vector atype = {0, 1, 1, 0, 1, 1}; + std::vector box = {13., 0., 0., 0., 13., 0., 0., 0., 13.}; + std::vector charge_spin = {1.0, 2.0}; + std::vector expected_force_explicit; + std::vector expected_force_default; + std::vector expected_atom_energy_explicit; + std::vector expected_atom_virial_explicit; + double expected_energy_explicit = 0.0; + double expected_energy_default = 0.0; + deepmd::DeepPot dp; + + void SetUp() override { +#ifndef BUILD_JAX + GTEST_SKIP() << "Skip because JAX support is not enabled."; +#endif + std::ifstream model_file(kModelPath); + if (!model_file.good()) { + GTEST_SKIP() << "Skip because " << kModelPath << " was not generated."; + } + dp.init(kModelPath); + + deepmd_test::ExpectedRef ref; + ref.load(kRefPath); + expected_atom_energy_explicit = + ref.get("explicit", "expected_e"); + expected_atom_virial_explicit = + ref.get("explicit", "expected_v"); + expected_force_explicit = ref.get("explicit", "expected_f"); + expected_force_default = ref.get("default", "expected_f"); + const auto expected_atom_energy_default = + ref.get("default", "expected_e"); + for (const auto value : expected_atom_energy_explicit) { + expected_energy_explicit += value; + } + for (const auto value : expected_atom_energy_default) { + expected_energy_default += value; + } + } + + void expect_force(const std::vector& actual, + const std::vector& expected) const { + ASSERT_EQ(actual.size(), expected.size()); + for (std::size_t ii = 0; ii < actual.size(); ++ii) { + EXPECT_NEAR(actual[ii], expected[ii], tolerance()); + } + } +}; + +TYPED_TEST_SUITE(TestInferDeepPotChgSpinJAX, ValueTypes); + +TYPED_TEST(TestInferDeepPotChgSpinJAX, explicit_and_default_inputs) { + using VALUETYPE = TypeParam; + double energy; + std::vector force, virial; + + this->dp.compute(energy, force, virial, this->coord, this->atype, this->box, + {}, {}, this->charge_spin); + EXPECT_NEAR(energy, this->expected_energy_explicit, tolerance()); + this->expect_force(force, this->expected_force_explicit); + + this->dp.compute(energy, force, virial, this->coord, this->atype, this->box); + EXPECT_NEAR(energy, this->expected_energy_default, tolerance()); + this->expect_force(force, this->expected_force_default); +} + +TYPED_TEST(TestInferDeepPotChgSpinJAX, atomic_output_overload) { + using VALUETYPE = TypeParam; + double energy; + std::vector force, virial, atom_energy, atom_virial; + + this->dp.compute(energy, force, virial, atom_energy, atom_virial, this->coord, + this->atype, this->box, {}, {}, this->charge_spin); + + EXPECT_NEAR(energy, this->expected_energy_explicit, tolerance()); + this->expect_force(force, this->expected_force_explicit); + this->expect_force(atom_energy, this->expected_atom_energy_explicit); + this->expect_force(atom_virial, this->expected_atom_virial_explicit); +} + +TYPED_TEST(TestInferDeepPotChgSpinJAX, broadcasts_one_frame_input) { + using VALUETYPE = TypeParam; + auto coord = this->coord; + coord.insert(coord.end(), this->coord.begin(), this->coord.end()); + auto box = this->box; + box.insert(box.end(), this->box.begin(), this->box.end()); + std::vector energy; + std::vector force, virial; + + this->dp.compute(energy, force, virial, coord, this->atype, box, {}, {}, + this->charge_spin); + + ASSERT_EQ(energy.size(), 2u); + EXPECT_NEAR(energy[0], this->expected_energy_explicit, + tolerance()); + EXPECT_NEAR(energy[1], this->expected_energy_explicit, + tolerance()); + ASSERT_EQ(force.size(), 2 * this->expected_force_explicit.size()); + for (std::size_t ii = 0; ii < force.size(); ++ii) { + EXPECT_NEAR( + force[ii], + this->expected_force_explicit[ii % + this->expected_force_explicit.size()], + tolerance()); + } +} + +TYPED_TEST(TestInferDeepPotChgSpinJAX, rejects_invalid_input_size) { + using VALUETYPE = TypeParam; + double energy; + std::vector force, virial; + const std::vector invalid_charge_spin = {1.0, 2.0, 3.0}; + + EXPECT_THROW(this->dp.compute(energy, force, virial, this->coord, this->atype, + this->box, {}, {}, invalid_charge_spin), + deepmd::deepmd_exception); +} + +TYPED_TEST(TestInferDeepPotChgSpinJAX, neighbor_list_overload) { + using VALUETYPE = TypeParam; + const int natoms = static_cast(this->atype.size()); + std::vector> nlist_data = {{1, 2, 3, 4, 5}, {0, 2, 3, 4, 5}, + {0, 1, 3, 4, 5}, {0, 1, 2, 4, 5}, + {0, 1, 2, 3, 5}, {0, 1, 2, 3, 4}}; + std::vector ilist(natoms), numneigh(natoms); + std::vector firstneigh(natoms); + deepmd::InputNlist inlist(natoms, ilist.data(), numneigh.data(), + firstneigh.data()); + convert_nlist(inlist, nlist_data); + double energy; + std::vector force, virial; + + this->dp.compute(energy, force, virial, this->coord, this->atype, {}, 0, + inlist, 0, {}, {}, this->charge_spin); + + deepmd_test::ExpectedRef ref; + ref.load(kRefPath); + const auto expected_energy = + ref.get("nopbc_explicit", "expected_e"); + const auto expected_force = + ref.get("nopbc_explicit", "expected_f"); + double expected_total_energy = 0.0; + for (const auto value : expected_energy) { + expected_total_energy += value; + } + EXPECT_NEAR(energy, expected_total_energy, tolerance()); + this->expect_force(force, expected_force); +} diff --git a/source/jax2tf_tests/test_deep_eval.py b/source/jax2tf_tests/test_deep_eval.py new file mode 100644 index 0000000000..3ac25bb414 --- /dev/null +++ b/source/jax2tf_tests/test_deep_eval.py @@ -0,0 +1,92 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +import numpy as np +import pytest + +from deepmd.jax.infer.deep_eval import ( + DeepEval, +) + + +class _NoChargeSpinModel: + def __init__(self) -> None: + self.kwargs = None + + def get_dim_fparam(self) -> int: + return 0 + + def get_dim_aparam(self) -> int: + return 0 + + def has_default_fparam(self) -> bool: + return False + + def has_chg_spin_ebd(self) -> bool: + return False + + def __call__(self, coord, atype, **kwargs): + del coord, atype + self.kwargs = kwargs + return {} + + +class _ChargeSpinModel(_NoChargeSpinModel): + def has_chg_spin_ebd(self) -> bool: + return True + + +def test_eval_model_warns_and_ignores_charge_spin_without_embedding() -> None: + deep_eval = object.__new__(DeepEval) + model = _NoChargeSpinModel() + deep_eval.dp = model + + with pytest.warns(UserWarning, match="will be ignored"): + deep_eval._eval_model( + np.zeros((1, 6)), + None, + np.array([0, 0], dtype=np.int32), + None, + None, + np.array([[1.0, 2.0]]), + [], + ) + + assert model.kwargs is not None + assert "charge_spin" not in model.kwargs + + +def test_eval_model_forwards_charge_spin_with_embedding() -> None: + deep_eval = object.__new__(DeepEval) + model = _ChargeSpinModel() + deep_eval.dp = model + + deep_eval._eval_model( + np.zeros((1, 6)), + None, + np.array([0, 0], dtype=np.int32), + None, + None, + np.array([[1.0, 2.0]]), + [], + ) + + assert model.kwargs is not None + np.testing.assert_allclose(np.asarray(model.kwargs["charge_spin"]), [[1.0, 2.0]]) + + +def test_eval_model_forwards_none_charge_spin_with_embedding() -> None: + deep_eval = object.__new__(DeepEval) + model = _ChargeSpinModel() + deep_eval.dp = model + + deep_eval._eval_model( + np.zeros((1, 6)), + None, + np.array([0, 0], dtype=np.int32), + None, + None, + None, + [], + ) + + assert model.kwargs is not None + assert model.kwargs["charge_spin"] is None diff --git a/source/jax2tf_tests/test_make_model.py b/source/jax2tf_tests/test_make_model.py index 18e830c162..96341173a7 100644 --- a/source/jax2tf_tests/test_make_model.py +++ b/source/jax2tf_tests/test_make_model.py @@ -59,6 +59,53 @@ def call_lower( ) return ret["coord_x"] + @tf.function( + input_signature=[ + tf.TensorSpec([None, None, 3], DTYPE), + tf.TensorSpec([None, None], tf.int32), + tf.TensorSpec([None, None, None], DTYPE), + tf.TensorSpec([None, 2], DTYPE), + ] + ) + def call_model_with_charge_spin( + self, + coord: tf.Tensor, + atype: tf.Tensor, + box: tf.Tensor, + charge_spin: tf.Tensor, + ) -> tf.Tensor: + def call_lower( + extended_coord: tf.Tensor, + extended_atype: tf.Tensor, + nlist: tf.Tensor, + mapping: tf.Tensor, + fparam: tf.Tensor, + aparam: tf.Tensor, + charge_spin: tf.Tensor, + ) -> dict[str, tf.Tensor]: + del extended_atype, nlist, mapping, fparam, aparam + return { + "coord_x": extended_coord[..., :1] + + tf.reshape(charge_spin[:, :1], [-1, 1, 1]) + } + + nframes = tf.shape(coord)[0] + nloc = tf.shape(atype)[1] + ret = model_call_from_call_lower( + call_lower=call_lower, + rcut=0.4, + sel=[1], + mixed_types=True, + model_output_def=self.output_def, + coord=coord, + atype=atype, + box=box, + fparam=tf.zeros([nframes, 0], dtype=DTYPE), + aparam=tf.zeros([nframes, nloc, 0], dtype=DTYPE), + charge_spin=charge_spin, + ) + return ret["coord_x"] + def test_model_call_without_box(self) -> None: coord = tf.constant([[[0.2, 0.0, 0.0], [0.8, 0.0, 0.0]]], dtype=DTYPE) atype = tf.constant([[0, 1]], dtype=tf.int32) @@ -76,3 +123,13 @@ def test_model_call_with_box_normalizes_coord(self) -> None: coord_x = self.call_model(coord, atype, box) self.assertAllClose(coord_x[:, :2], [[[0.2], [0.8]]]) + + def test_model_call_forwards_charge_spin(self) -> None: + coord = tf.constant([[[0.2, 0.0, 0.0], [0.8, 0.0, 0.0]]], dtype=DTYPE) + atype = tf.constant([[0, 1]], dtype=tf.int32) + box = tf.zeros([1, 0, 0], dtype=DTYPE) + charge_spin = tf.constant([[2.0, 1.0]], dtype=DTYPE) + + coord_x = self.call_model_with_charge_spin(coord, atype, box, charge_spin) + + self.assertAllClose(coord_x, coord[..., :1] + 2.0) diff --git a/source/jax2tf_tests/test_serialization.py b/source/jax2tf_tests/test_serialization.py index cc352f33df..27751b7c43 100644 --- a/source/jax2tf_tests/test_serialization.py +++ b/source/jax2tf_tests/test_serialization.py @@ -3,7 +3,9 @@ Path, ) +import numpy as np import pytest +import tensorflow as tf from tensorflow.core.protobuf import ( saved_model_pb2, ) @@ -38,6 +40,8 @@ def test_savedmodel_export_contains_xla_call_module(tmp_path, monkeypatch) -> No ) class DummyModel: + dim_chg_spin = 0 + def call_common_lower( self, coord, @@ -46,12 +50,19 @@ def call_common_lower( mapping, fparam, aparam, + charge_spin=None, do_atomic_virial: bool = False, ): del nlist, mapping, fparam, aparam, do_atomic_virial + charge_spin_offset = ( + 0.0 + if charge_spin is None + else jnp.asarray(charge_spin[:, None, :1], dtype=coord.dtype) + ) return { "coord_x": coord[..., :1] + jnp.asarray(atype[..., None], dtype=coord.dtype) * 0.0 + + charge_spin_offset } def get_nnei(self) -> int: @@ -99,6 +110,21 @@ def has_default_fparam(self) -> bool: def get_default_fparam(self) -> None: return None + def has_chg_spin_ebd(self) -> bool: + return self.dim_chg_spin > 0 + + def get_dim_chg_spin(self) -> int: + return self.dim_chg_spin + + def has_default_chg_spin(self) -> bool: + return False + + def get_default_chg_spin(self) -> None: + return None + + class DummyChargeSpinModel(DummyModel): + dim_chg_spin = 2 + monkeypatch.setattr( serialization.BaseModel, "deserialize", @@ -112,3 +138,33 @@ def get_default_fparam(self) -> None: ) assert "XlaCallModule" in _saved_model_ops(model_dir) + + monkeypatch.setattr( + serialization.BaseModel, + "deserialize", + staticmethod(lambda data: DummyChargeSpinModel()), + ) + + charge_spin_model_dir = tmp_path / "dummy_chg_spin.savedmodel" + serialization.deserialize_to_file( + str(charge_spin_model_dir), + {"model": {"type": "dummy"}, "model_def_script": {"type": "dummy"}}, + ) + + assert "XlaCallModule" in _saved_model_ops(charge_spin_model_dir) + + loaded_model = tf.saved_model.load(str(charge_spin_model_dir)) + coord = tf.constant([[[0.2, 0.0, 0.0], [0.8, 0.0, 0.0]]], dtype=tf.float64) + atype = tf.constant([[0, 0]], dtype=tf.int32) + result = loaded_model.call( + coord, + atype, + tf.zeros([1, 0, 0], dtype=tf.float64), + tf.zeros([1, 0], dtype=tf.float64), + tf.zeros([1, 2, 0], dtype=tf.float64), + tf.constant([[2.0, 1.0]], dtype=tf.float64), + ) + np.testing.assert_allclose( + result["coord_x"].numpy(), + np.array([[[2.2], [2.8]]]), + ) diff --git a/source/jax2tf_tests/test_tfmodel.py b/source/jax2tf_tests/test_tfmodel.py new file mode 100644 index 0000000000..51483ada4a --- /dev/null +++ b/source/jax2tf_tests/test_tfmodel.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +import numpy as np +import pytest + +from deepmd.jax.jax2tf.tfmodel import ( + TFModelWrapper, +) + + +def _make_wrapper( + default_chg_spin: list[float] | None = None, +) -> TFModelWrapper: + wrapper = object.__new__(TFModelWrapper) + wrapper.dim_chg_spin = 2 + wrapper._has_default_chg_spin = default_chg_spin is not None + wrapper.default_chg_spin = default_chg_spin + return wrapper + + +def test_make_charge_spin_input_uses_default() -> None: + wrapper = _make_wrapper([0.0, 1.0]) + + charge_spin = wrapper._make_charge_spin_input(3, None) + + np.testing.assert_allclose( + np.asarray(charge_spin), + np.array([[0.0, 1.0], [0.0, 1.0], [0.0, 1.0]]), + ) + + +def test_make_charge_spin_input_broadcasts_explicit_single_frame() -> None: + wrapper = _make_wrapper([0.0, 1.0]) + + charge_spin = wrapper._make_charge_spin_input(3, np.array([2.0, 1.0])) + + np.testing.assert_allclose( + np.asarray(charge_spin), + np.array([[2.0, 1.0], [2.0, 1.0], [2.0, 1.0]]), + ) + + +def test_make_charge_spin_input_requires_default_or_explicit_value() -> None: + wrapper = _make_wrapper() + + with pytest.raises(ValueError, match="charge_spin is required"): + wrapper._make_charge_spin_input(1, None) + + +@pytest.mark.parametrize( + ("charge_spin", "match"), + [ + (np.array([1.0]), "contain"), + (np.zeros((3, 1)), "shape"), + (np.zeros((2, 2, 2)), "shape"), + (np.zeros((2, 2)), "first dimension"), + ], +) +def test_make_charge_spin_input_rejects_invalid_shapes( + charge_spin: np.ndarray, match: str +) -> None: + wrapper = _make_wrapper([0.0, 1.0]) + + with pytest.raises(ValueError, match=match): + wrapper._make_charge_spin_input(3, charge_spin) diff --git a/source/tests/infer/gen_chg_spin.py b/source/tests/infer/gen_chg_spin.py index 327d967af4..c3ea7adb09 100644 --- a/source/tests/infer/gen_chg_spin.py +++ b/source/tests/infer/gen_chg_spin.py @@ -1,10 +1,10 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: LGPL-3.0-or-later -"""Generate chg_spin.pt2 / chg_spin.pth test models and reference values. +"""Generate charge-spin test models and reference values. Creates a DPA3 model with add_chg_spin_ebd=True and default_chg_spin=[0.0, 1.0], -exports it to both .pt2 (AOTInductor) and .pth (TorchScript) from the same -weights, and writes chg_spin.expected with two sections: +exports it to .pt2 (AOTInductor), .pth (TorchScript), and .savedmodel (JAX) +from the same weights, and writes chg_spin.expected with two sections: [default] -- eval with no charge_spin (uses stored default [0.0, 1.0]) [explicit] -- eval with charge_spin=[1.0, 2.0] The .pth is verified to match the .pt2 reference for both sections. @@ -73,6 +73,9 @@ def main(): "version": "3.0.0", } + from deepmd.jax.utils.serialization import ( + deserialize_to_file as jax_deserialize_to_file, + ) from deepmd.pt.utils.serialization import ( deserialize_to_file as pt_deserialize_to_file, ) @@ -87,6 +90,10 @@ def main(): print(f"Exporting to {pt2_path} ...") # noqa: T201 pt_expt_deserialize_to_file(pt2_path, copy.deepcopy(data), do_atomic_virial=True) + savedmodel_path = os.path.join(base_dir, "chg_spin.savedmodel") + print(f"Exporting to {savedmodel_path} ...") # noqa: T201 + jax_deserialize_to_file(savedmodel_path, copy.deepcopy(data)) + pth_path = os.path.join(base_dir, "chg_spin.pth") # Remove any stale .pth first so a failed export below cannot leave an old # artifact that the parity check would then validate against.