Skip to content

Commit 2ccd120

Browse files
author
Han Wang
committed
feat(dpmodel): attach_angles post-hoc angle attachment
1 parent 1e134a1 commit 2ccd120

3 files changed

Lines changed: 136 additions & 1 deletion

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+
attach_angles,
1314
build_angle_index,
1415
)
1516
from .ase_builder import (
@@ -49,6 +50,7 @@
4950
__all__ = [
5051
"GraphLayout",
5152
"NeighborGraph",
53+
"attach_angles",
5254
"build_angle_index",
5355
"build_neighbor_graph",
5456
"build_neighbor_graph_ase",

deepmd/dpmodel/utils/neighbor_graph/angles.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515
if TYPE_CHECKING:
1616
from deepmd.dpmodel.array_api import Array
1717

18-
from .graph import GraphLayout, pad_and_guard_angles
18+
import dataclasses
19+
20+
from .graph import GraphLayout, NeighborGraph, pad_and_guard_angles
1921
from .pairs import center_edge_pairs
2022

2123

@@ -89,3 +91,50 @@ def build_angle_index(
8991
axis=0,
9092
)
9193
return ai, am & pm_padded
94+
95+
96+
def attach_angles(
97+
graph: NeighborGraph,
98+
a_rcut: float,
99+
*,
100+
ordered: bool = False,
101+
include_self: bool = False,
102+
layout: GraphLayout | None = None,
103+
) -> NeighborGraph:
104+
"""Attach angle_index/angle_mask to an existing edge-only NeighborGraph.
105+
106+
Parameters
107+
----------
108+
graph : NeighborGraph
109+
Input graph (edge fields must be populated).
110+
a_rcut : float
111+
Angle cutoff radius. Only edges with norm < a_rcut participate.
112+
ordered : bool, optional
113+
If True, include both (a, b) and (b, a) angle pairs.
114+
include_self : bool, optional
115+
If True, include self-angle pairs (a, a).
116+
layout : GraphLayout or None, optional
117+
If provided, uses layout.angle_capacity and layout.node_capacity.
118+
119+
Returns
120+
-------
121+
NeighborGraph
122+
A new NeighborGraph with angle_index and angle_mask populated;
123+
all edge/node fields are unchanged.
124+
"""
125+
xp = array_api_compat.array_namespace(graph.edge_index)
126+
if layout is not None and layout.node_capacity is not None:
127+
n_total = layout.node_capacity
128+
else:
129+
n_total = int(xp.sum(graph.n_node))
130+
ai, am = build_angle_index(
131+
graph.edge_index,
132+
graph.edge_vec,
133+
graph.edge_mask,
134+
n_total,
135+
a_rcut,
136+
ordered=ordered,
137+
include_self=include_self,
138+
layout=layout,
139+
)
140+
return dataclasses.replace(graph, angle_index=ai, angle_mask=am)

source/tests/common/dpmodel/test_angle_builder.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33

44
from deepmd.dpmodel.utils.neighbor_graph import (
55
GraphLayout,
6+
attach_angles,
67
build_angle_index,
8+
build_neighbor_graph,
79
pad_and_guard_angles,
810
)
911

@@ -203,3 +205,85 @@ def test_build_angle_index_ordered_no_self():
203205
rev_pair = (b, a)
204206
if a != b and a not in [2] and b not in [2]: # edge 2 is outside a_rcut
205207
assert rev_pair in got # symmetric pairs should both exist
208+
209+
210+
# ---------------------------------------------------------------------------
211+
# Task 3: attach_angles tests
212+
# ---------------------------------------------------------------------------
213+
214+
215+
def test_attach_angles_sets_fields_and_preserves_edges():
216+
"""attach_angles populates angle_index/mask; edge fields are unchanged."""
217+
coord = np.array([[[0.0, 0, 0], [0.8, 0, 0], [0, 0.8, 0]]])
218+
atype = np.array([[0, 0, 0]]) # (nf, nloc)
219+
ng = build_neighbor_graph(coord, atype, None, 2.0)
220+
# default carry-all builder leaves angles None
221+
assert ng.angle_index is None
222+
assert ng.angle_mask is None
223+
ng2 = attach_angles(ng, a_rcut=1.5)
224+
assert ng2.angle_index is not None and ng2.angle_mask is not None
225+
# edge fields must be identical (by value and shape)
226+
np.testing.assert_array_equal(np.asarray(ng2.edge_index), np.asarray(ng.edge_index))
227+
np.testing.assert_array_equal(np.asarray(ng2.edge_mask), np.asarray(ng.edge_mask))
228+
np.testing.assert_array_equal(np.asarray(ng2.edge_vec), np.asarray(ng.edge_vec))
229+
230+
231+
def test_attach_angles_angle_shape_consistent():
232+
"""angle_index has shape (2, A) and angle_mask has shape (A,)."""
233+
coord = np.array([[[0.0, 0, 0], [0.5, 0, 0], [0, 0.5, 0]]])
234+
atype = np.array([[0, 0, 0]]) # (nf, nloc)
235+
ng = build_neighbor_graph(coord, atype, None, 2.0)
236+
ng2 = attach_angles(ng, a_rcut=1.5)
237+
assert ng2.angle_index.shape[0] == 2
238+
assert ng2.angle_mask.shape[0] == ng2.angle_index.shape[1]
239+
240+
241+
def test_attach_angles_valid_angles_reference_valid_edges():
242+
"""All valid angle pairs (q_e, k_e) must index edges that are within a_rcut."""
243+
coord = np.array([[[0.0, 0, 0], [0.6, 0, 0], [0, 0.6, 0]]])
244+
atype = np.array([[0, 0, 0]]) # (nf, nloc)
245+
ng = build_neighbor_graph(coord, atype, None, 2.0)
246+
ng2 = attach_angles(ng, a_rcut=1.0)
247+
ei = np.asarray(ng2.edge_index)
248+
ev = np.asarray(ng2.edge_vec)
249+
em = np.asarray(ng2.edge_mask)
250+
ai = np.asarray(ng2.angle_index)
251+
am = np.asarray(ng2.angle_mask)
252+
for p in range(am.shape[0]):
253+
if not am[p]:
254+
continue
255+
q, k = int(ai[0, p]), int(ai[1, p])
256+
# both referenced edges must be valid and within a_rcut
257+
assert em[q] and em[k]
258+
assert np.linalg.norm(ev[q]) < 1.0
259+
assert np.linalg.norm(ev[k]) < 1.0
260+
# both referenced edges must share the same center (dst)
261+
assert ei[1, q] == ei[1, k]
262+
263+
264+
def test_attach_angles_with_layout():
265+
"""Static layout.angle_capacity is respected."""
266+
coord = np.array([[[0.0, 0, 0], [0.6, 0, 0], [0, 0.6, 0]]])
267+
atype = np.array([[0, 0, 0]]) # (nf, nloc)
268+
ng = build_neighbor_graph(coord, atype, None, 2.0)
269+
layout = GraphLayout(edge_capacity=100, angle_capacity=20)
270+
ng2 = attach_angles(ng, a_rcut=1.5, layout=layout)
271+
assert ng2.angle_index.shape == (2, 20)
272+
assert ng2.angle_mask.shape == (20,)
273+
274+
275+
def test_attach_angles_ordered_include_self():
276+
"""ordered=True, include_self=True produces a superset of default pairs."""
277+
coord = np.array([[[0.0, 0, 0], [0.5, 0, 0], [0, 0.5, 0]]])
278+
atype = np.array([[0, 0, 0]]) # (nf, nloc)
279+
ng = build_neighbor_graph(coord, atype, None, 2.0)
280+
ng_default = attach_angles(ng, a_rcut=1.5)
281+
ng_full = attach_angles(ng, a_rcut=1.5, ordered=True, include_self=True)
282+
ai_def = np.asarray(ng_default.angle_index)
283+
am_def = np.asarray(ng_default.angle_mask)
284+
ai_full = np.asarray(ng_full.angle_index)
285+
am_full = np.asarray(ng_full.angle_mask)
286+
n_default = int(am_def.sum())
287+
n_full = int(am_full.sum())
288+
# ordered+include_self must produce at least as many angles as default
289+
assert n_full >= n_default

0 commit comments

Comments
 (0)