Skip to content

Commit 0b1b49f

Browse files
author
Han Wang
committed
feat(dpmodel): build_angle_index (edge pairs within a_rcut)
1 parent 2560148 commit 0b1b49f

3 files changed

Lines changed: 194 additions & 1 deletion

File tree

deepmd/dpmodel/utils/neighbor_graph/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
See the design discussion wanghan-iapcm/deepmd-kit#4.
1010
"""
1111

12+
from .angles import (
13+
build_angle_index,
14+
)
1215
from .ase_builder import (
1316
build_neighbor_graph_ase,
1417
)
@@ -46,6 +49,7 @@
4649
__all__ = [
4750
"GraphLayout",
4851
"NeighborGraph",
52+
"build_angle_index",
4953
"build_neighbor_graph",
5054
"build_neighbor_graph_ase",
5155
"center_edge_pairs",
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# SPDX-License-Identifier: LGPL-3.0-or-later
2+
"""3-body angle graph: pairs of edges sharing a center within a_rcut.
3+
4+
Angles reference EDGES (angle_index into [0,E)); edge_vec stays the only
5+
geometry leaf. a_sel is normalization-only (not a truncation). Reuses PR-D's
6+
center_edge_pairs; a_rcut filters the participating edges.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import TYPE_CHECKING
12+
13+
import array_api_compat
14+
15+
if TYPE_CHECKING:
16+
from deepmd.dpmodel.array_api import Array
17+
18+
from .graph import GraphLayout, pad_and_guard_angles
19+
from .pairs import center_edge_pairs
20+
21+
22+
def build_angle_index(
23+
edge_index: Array,
24+
edge_vec: Array,
25+
edge_mask: Array,
26+
n_total: int,
27+
a_rcut: float,
28+
*,
29+
ordered: bool = False,
30+
include_self: bool = False,
31+
layout: GraphLayout | None = None,
32+
) -> tuple[Array, Array]:
33+
"""Build angle index for 3-body terms.
34+
35+
Parameters
36+
----------
37+
edge_index : Array
38+
Shape (2, E) [src, dst] SoA edge indices.
39+
edge_vec : Array
40+
Shape (E, 3) edge vectors (neighbor - center).
41+
edge_mask : Array
42+
Shape (E,) boolean validity mask for edges.
43+
n_total : int
44+
Total number of nodes.
45+
a_rcut : float
46+
Angle cutoff. Only edges with norm < a_rcut participate in angles.
47+
ordered : bool, optional
48+
If True, include both (a, b) and (b, a) pairs (ordered pairs).
49+
include_self : bool, optional
50+
If True, include self-angle pairs (a, a).
51+
layout : GraphLayout or None, optional
52+
If provided, uses layout.angle_capacity as static padding capacity.
53+
54+
Returns
55+
-------
56+
angle_index : Array
57+
Shape (2, A) index pairs into the edge list.
58+
angle_mask : Array
59+
Shape (A,) boolean mask for valid angles.
60+
"""
61+
xp = array_api_compat.array_namespace(edge_index)
62+
# a_rcut edge gate: only edges within a_rcut may participate in an angle
63+
dist = xp.linalg.vector_norm(edge_vec, axis=-1) # (E,)
64+
a_edge_mask = xp.astype(edge_mask, xp.bool) & (dist < a_rcut)
65+
# compact eager form only (static_nnei not exposed until angle export is
66+
# needed, PR-G). dst = edge_index[1, :] per the [src, dst] SoA convention.
67+
q_e, k_e, pair_mask = center_edge_pairs(
68+
edge_index[1, :],
69+
a_edge_mask,
70+
n_total,
71+
include_self=include_self,
72+
ordered=ordered,
73+
)
74+
# compact form returns all-True pair_mask, but NEVER discard it: the
75+
# shape-static form keeps filtered pairs and invalidates them only here.
76+
angle_index = xp.stack([q_e, k_e], axis=0) # (2, A_real)
77+
cap = layout.angle_capacity if layout is not None else None
78+
ai, am = pad_and_guard_angles(angle_index, cap, min_angles=2)
79+
# fold pair_mask into the real-angle prefix of the padded mask
80+
pm_padded = xp.concat(
81+
[
82+
pair_mask,
83+
xp.zeros(
84+
(am.shape[0] - pair_mask.shape[0],),
85+
dtype=xp.bool,
86+
device=array_api_compat.device(pair_mask),
87+
),
88+
],
89+
axis=0,
90+
)
91+
return ai, am & pm_padded

source/tests/common/dpmodel/test_angle_builder.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import numpy as np
22
import pytest
33

4-
from deepmd.dpmodel.utils.neighbor_graph import pad_and_guard_angles
4+
from deepmd.dpmodel.utils.neighbor_graph import build_angle_index, pad_and_guard_angles
55

66

77
def test_pad_angles_dynamic_appends_min_guard():
@@ -22,3 +22,101 @@ def test_pad_angles_overflow_raises():
2222
ai = np.zeros((2, 6), dtype=np.int64)
2323
with pytest.raises(ValueError):
2424
pad_and_guard_angles(ai, angle_capacity=4)
25+
26+
27+
def _angle_oracle(dst, evnorm, mask, a_rcut, ordered, include_self):
28+
"""All (edge_a, edge_b) sharing a center, both edges within a_rcut."""
29+
out = set()
30+
for a in range(len(dst)):
31+
if not mask[a] or evnorm[a] >= a_rcut:
32+
continue
33+
for b in range(len(dst)):
34+
if not mask[b] or evnorm[b] >= a_rcut or dst[a] != dst[b]:
35+
continue
36+
if not include_self and a == b:
37+
continue
38+
if not ordered and b < a:
39+
continue
40+
out.add((a, b))
41+
return out
42+
43+
44+
def test_build_angle_index_matches_oracle():
45+
# 4 edges, all dst=0; norms [0.5, 0.9, 2.5, 0.7], a_rcut=1.0
46+
# DEFAULT = unordered, no-self => within a_rcut {0,1,3} give {(0,1),(0,3),(1,3)}
47+
edge_index = np.array([[1, 2, 3, 1], [0, 0, 0, 0]], dtype=np.int64)
48+
edge_vec = np.array([[0.5, 0, 0], [0.9, 0, 0], [2.5, 0, 0], [0.7, 0, 0]])
49+
edge_mask = np.array([True, True, True, True])
50+
ai, am = build_angle_index(edge_index, edge_vec, edge_mask, 4, a_rcut=1.0)
51+
got = {(int(ai[0, p]), int(ai[1, p])) for p in range(ai.shape[1]) if am[p]}
52+
evnorm = np.linalg.norm(edge_vec, axis=-1)
53+
assert got == _angle_oracle(
54+
[0, 0, 0, 0], evnorm, edge_mask, 1.0, ordered=False, include_self=False
55+
)
56+
assert got == {
57+
(0, 1),
58+
(0, 3),
59+
(1, 3),
60+
} # unordered, no-self, edge 2 dropped (norm>a_rcut)
61+
assert all(
62+
2 not in (int(ai[0, p]), int(ai[1, p])) for p in range(ai.shape[1]) if am[p]
63+
)
64+
assert all(
65+
int(ai[0, p]) != int(ai[1, p]) for p in range(ai.shape[1]) if am[p]
66+
) # no self
67+
68+
69+
def test_build_angle_index_ordered_include_self():
70+
# ordered + include_self: (0,0),(0,1),(0,3),(1,0),(1,1),(1,3),(3,0),(3,1),(3,3)
71+
edge_index = np.array([[1, 2, 3, 1], [0, 0, 0, 0]], dtype=np.int64)
72+
edge_vec = np.array([[0.5, 0, 0], [0.9, 0, 0], [2.5, 0, 0], [0.7, 0, 0]])
73+
edge_mask = np.array([True, True, True, True])
74+
ai, am = build_angle_index(
75+
edge_index, edge_vec, edge_mask, 4, a_rcut=1.0, ordered=True, include_self=True
76+
)
77+
got = {(int(ai[0, p]), int(ai[1, p])) for p in range(ai.shape[1]) if am[p]}
78+
evnorm = np.linalg.norm(edge_vec, axis=-1)
79+
assert got == _angle_oracle(
80+
[0, 0, 0, 0], evnorm, edge_mask, 1.0, ordered=True, include_self=True
81+
)
82+
83+
84+
def test_build_angle_index_masked_edge():
85+
# edge 1 masked out — should not appear in any angle
86+
edge_index = np.array([[1, 2, 3, 1], [0, 0, 0, 0]], dtype=np.int64)
87+
edge_vec = np.array([[0.5, 0, 0], [0.9, 0, 0], [0.3, 0, 0], [0.7, 0, 0]])
88+
edge_mask = np.array([True, False, True, True])
89+
ai, am = build_angle_index(edge_index, edge_vec, edge_mask, 4, a_rcut=1.0)
90+
got = {(int(ai[0, p]), int(ai[1, p])) for p in range(ai.shape[1]) if am[p]}
91+
evnorm = np.linalg.norm(edge_vec, axis=-1)
92+
assert got == _angle_oracle(
93+
[0, 0, 0, 0], evnorm, edge_mask, 1.0, ordered=False, include_self=False
94+
)
95+
assert all(
96+
1 not in (int(ai[0, p]), int(ai[1, p])) for p in range(ai.shape[1]) if am[p]
97+
)
98+
99+
100+
def test_build_angle_index_torch_namespace():
101+
# Step 4b: torch-namespace smoke test (function-level import for TID253)
102+
import torch
103+
104+
edge_index = np.array([[1, 2, 3, 1], [0, 0, 0, 0]], dtype=np.int64)
105+
edge_vec = np.array([[0.5, 0, 0], [0.9, 0, 0], [2.5, 0, 0], [0.7, 0, 0]])
106+
edge_mask = np.array([True, True, True, True])
107+
108+
ai_np, am_np = build_angle_index(edge_index, edge_vec, edge_mask, 4, a_rcut=1.0)
109+
got_np = {
110+
(int(ai_np[0, p]), int(ai_np[1, p])) for p in range(ai_np.shape[1]) if am_np[p]
111+
}
112+
113+
t_edge_index = torch.from_numpy(edge_index)
114+
t_edge_vec = torch.from_numpy(edge_vec)
115+
t_edge_mask = torch.from_numpy(edge_mask)
116+
ai_t, am_t = build_angle_index(t_edge_index, t_edge_vec, t_edge_mask, 4, a_rcut=1.0)
117+
got_t = {
118+
(int(ai_t[0, p].item()), int(ai_t[1, p].item()))
119+
for p in range(ai_t.shape[1])
120+
if am_t[p].item()
121+
}
122+
assert got_t == got_np

0 commit comments

Comments
 (0)