Skip to content

Commit d762883

Browse files
author
Han Wang
committed
feat(dpmodel): angle-force invariance test + padding-fraction report
1 parent df28cdc commit d762883

3 files changed

Lines changed: 102 additions & 0 deletions

File tree

deepmd/dpmodel/utils/neighbor_graph/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"""
1111

1212
from .angles import (
13+
angle_padding_fraction,
1314
angle_to_edge_sum,
1415
angle_to_node_sum,
1516
attach_angles,
@@ -53,6 +54,7 @@
5354
__all__ = [
5455
"GraphLayout",
5556
"NeighborGraph",
57+
"angle_padding_fraction",
5658
"angle_to_edge_sum",
5759
"angle_to_node_sum",
5860
"attach_angles",

deepmd/dpmodel/utils/neighbor_graph/angles.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,3 +214,29 @@ def angle_to_node_sum(
214214
xp = array_api_compat.array_namespace(data)
215215
center = xp.take(edge_index[1, :], angle_index[0, :], axis=0)
216216
return segment_sum(data, center, num_nodes)
217+
218+
219+
def angle_padding_fraction(graph: NeighborGraph) -> float:
220+
"""Return the fraction of angle slots that are padding (guard entries).
221+
222+
Parameters
223+
----------
224+
graph : NeighborGraph
225+
A graph with ``angle_mask`` set (i.e., after :func:`attach_angles`
226+
with a static ``GraphLayout.angle_capacity``).
227+
228+
Returns
229+
-------
230+
float
231+
``1 - A_real / A_max`` where ``A_real`` is the count of valid angles
232+
and ``A_max`` is ``angle_mask.shape[0]``. Returns ``0.0`` when the
233+
mask is empty.
234+
"""
235+
if graph.angle_mask is None:
236+
return 0.0
237+
xp = array_api_compat.array_namespace(graph.angle_mask)
238+
total = graph.angle_mask.shape[0]
239+
if total == 0:
240+
return 0.0
241+
real = int(xp.sum(xp.astype(graph.angle_mask, xp.int64)))
242+
return 1.0 - real / total

source/tests/common/dpmodel/test_angle_builder.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33

44
from deepmd.dpmodel.utils.neighbor_graph import (
55
GraphLayout,
6+
angle_padding_fraction,
67
angle_to_edge_sum,
78
angle_to_node_sum,
89
attach_angles,
910
build_angle_index,
1011
build_neighbor_graph,
12+
edge_force_virial,
1113
pad_and_guard_angles,
1214
)
1315

@@ -358,3 +360,75 @@ def test_angle_aggregation_torch_namespace():
358360
# compare
359361
np.testing.assert_allclose(np.asarray(e_t), e_np)
360362
np.testing.assert_allclose(np.asarray(n_t), n_np)
363+
364+
365+
# ---------------------------------------------------------------------------
366+
# Task 6: angle-force invariance + angle_padding_fraction
367+
# ---------------------------------------------------------------------------
368+
369+
370+
def _small_graph_with_angles(a_rcut: float, layout: GraphLayout | None = None):
371+
"""Return (graph_no_angles, graph_with_angles) for a 3-atom, 1-frame system."""
372+
# 3 atoms: 0,1,2 in a line along x; shape (nf=1, nloc=3, 3) / (nf=1, nloc=3)
373+
coord = np.array([[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0]]])
374+
atype = np.array([[0, 1, 0]], dtype=np.int64)
375+
box = np.array([[[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]]])
376+
g = build_neighbor_graph(coord, atype, box, rcut=3.0)
377+
g_with = attach_angles(g, a_rcut, layout=layout)
378+
return g, g_with
379+
380+
381+
def test_edge_force_virial_ignores_angles():
382+
"""edge_force_virial output must be bit-identical with or without angles.
383+
384+
Angles add topology (angle_index/angle_mask) to the NeighborGraph but do
385+
NOT change edge_vec, edge_index, or edge_mask — the only inputs to
386+
edge_force_virial. This test proves that the angle fields are truly
387+
transparent to the force/virial assembly.
388+
"""
389+
g_bare, g_with_angles = _small_graph_with_angles(a_rcut=1.5)
390+
391+
# Manufacture a fake per-edge gradient (same shape as edge_vec)
392+
rng = np.random.default_rng(42)
393+
n_edges = int(g_bare.edge_index.shape[1])
394+
g_e = rng.standard_normal((n_edges, 3))
395+
396+
def run(graph):
397+
return edge_force_virial(
398+
g_e,
399+
graph.edge_vec,
400+
graph.edge_index,
401+
graph.edge_mask,
402+
graph.n_node,
403+
)
404+
405+
force_bare, av_bare, vir_bare = run(g_bare)
406+
force_with, av_with, vir_with = run(g_with_angles)
407+
408+
# Exact equality: same inputs → same computation → identical bits
409+
np.testing.assert_array_equal(force_bare, force_with)
410+
np.testing.assert_array_equal(av_bare, av_with)
411+
np.testing.assert_array_equal(vir_bare, vir_with)
412+
413+
414+
def test_angle_padding_fraction():
415+
"""angle_padding_fraction returns 1 - A_real/A_max for a static layout.
416+
417+
We build with a fixed angle_capacity=A_max so the fraction is deterministic
418+
(not influenced by the dynamic min_angles guard of pad_and_guard_angles).
419+
"""
420+
A_max = 20 # static capacity, larger than any real angle count
421+
layout = GraphLayout(angle_capacity=A_max)
422+
g_bare, g_with = _small_graph_with_angles(a_rcut=1.5, layout=layout)
423+
424+
# Confirm angles are present
425+
assert g_with.angle_mask is not None
426+
A_real = int(np.sum(g_with.angle_mask))
427+
assert 0 < A_real <= A_max, f"Expected 0 < A_real <= {A_max}, got {A_real}"
428+
429+
expected = 1.0 - A_real / A_max
430+
got = angle_padding_fraction(g_with)
431+
assert got == pytest.approx(expected), f"got {got}, expected {expected}"
432+
433+
# No angles → fraction is 0.0
434+
assert angle_padding_fraction(g_bare) == 0.0

0 commit comments

Comments
 (0)