| name | nvalchemi-data-structures |
| description | How to use AtomicData and Batch, the core graph-based data structures for representing atomic systems and batching them for GPU computation. Use when building systems from positions, cells, and atomic numbers, converting from ASE Atoms, batching or unbatching structures, reading per-atom vs per-graph tensors, or debugging shape, dtype, or device errors in model inputs. |
nvalchemi Data Structures
Overview
nvalchemi represents atomic systems as graphs using two core classes:
AtomicData — a single atomic system (molecule, crystal, etc.)
Batch — an efficient container of multiple AtomicData objects
stored as concatenated tensors
Both are Pydantic BaseModel subclasses with DataMixin for device/dtype operations.
from nvalchemi.data import AtomicData, Batch
AtomicData
Construction
Required fields: positions [n_nodes, 3] and atomic_numbers [n_nodes].
import torch
data = AtomicData(
positions=torch.randn(4, 3),
atomic_numbers=torch.tensor([1, 6, 6, 1], dtype=torch.long),
)
data = AtomicData(
positions=torch.randn(4, 3),
atomic_numbers=torch.tensor([1, 6, 6, 1], dtype=torch.long),
neighbor_list=torch.tensor([[0, 1], [1, 0], [1, 2], [2, 1]], dtype=torch.long),
)
data = AtomicData(
positions=torch.randn(4, 3),
atomic_numbers=torch.tensor([1, 6, 6, 1], dtype=torch.long),
energy=torch.tensor([[0.5]]),
cell=torch.eye(3).unsqueeze(0),
pbc=torch.tensor([[True, True, False]]),
)
From ASE Atoms:
data = AtomicData.from_atoms(
atoms,
energy_key="energy",
forces_key="forces",
device="cpu",
dtype=torch.float32,
)
Field reference
Fields are organized by level. All are optional except positions and atomic_numbers.
| Level | Field | Shape | Notes |
|---|
| Node | atomic_numbers | [V] | Required, int64 |
| Node | positions | [V, 3] | Required, float |
| Node | atomic_masses | [V] | Auto-populated from periodic table |
| Node | atom_categories | [V] | Defaults to zeros |
| Node | forces | [V, 3] | eV/Angstrom |
| Node | velocities | [V, 3] | Auto-initialized to zeros |
| Node | momenta | [V, 3] | |
| Node | charges | [V, 1] | |
| Node | node_embeddings | [V, H] | |
| Node | kinetic_energies | [V, 1] | |
| Edge | neighbor_list | [E, 2] | COO format, int64 |
| Edge | shifts | [E, 3] | Cartesian displacements (neighbor_list_shifts @ cell) |
| Edge | neighbor_list_shifts | [E, 3] | Integer lattice image indices |
| Edge | edge_embeddings | [E, H] | |
| Dense | neighbor_matrix | [V, K] | Dense neighbor matrix (int64) |
| Dense | neighbor_matrix_shifts |
Custom data can be stored in the info: dict[str, torch.Tensor] field.
Properties
data.num_nodes
data.num_edges
data.device
data.dtype
data.chemical_hash
data.node_properties
data.edge_properties
data.system_properties
Dict-like access
data["positions"]
data["positions"] = new_tensor
Adding custom properties
data.add_node_property("custom_feat", torch.randn(data.num_nodes, 4))
data.add_edge_property("edge_weights", torch.ones(data.num_edges))
data.add_system_property("temperature", torch.tensor([[300.0]]))
Device, clone, serialization
data.to("cuda")
data.to("cpu", dtype=torch.float64)
data.cpu()
data.cuda()
data.clone()
data.model_dump(exclude_none=True)
data.model_dump_json()
Equality
Two AtomicData objects are equal if they have the same chemical_hash:
data1 == data2
Batch
Construction
data_list = [
AtomicData(positions=torch.randn(2, 3), atomic_numbers=torch.ones(2, dtype=torch.long)),
AtomicData(positions=torch.randn(3, 3), atomic_numbers=torch.ones(3, dtype=torch.long)),
]
batch = Batch.from_data_list(data_list)
batch = Batch.from_data_list(data_list, exclude_keys=["velocities"])
buffer = Batch.empty(
num_systems=40, num_nodes=80, num_edges=80,
template=data_list[0],
)
Size properties
batch.num_graphs
batch.batch_size
batch.num_nodes
batch.num_edges
batch.batch_idx
batch.batch_ptr
batch.num_nodes_list
batch.num_edges_list
batch.num_nodes_per_graph
batch.num_edges_per_graph
batch.max_num_nodes
batch.system_capacity
Indexing
batch[0]
batch[-1]
batch.get_data(0)
batch[1:3]
batch[torch.tensor([0, 2])]
batch[[0, 2]]
batch[torch.tensor([True, False, True])]
batch["positions"]
all_graphs = batch.to_data_list()
Containment, length, iteration
"positions" in batch
len(batch)
for key, tensor in batch:
...
Mutation
batch.add_key("node_feat", [torch.randn(2, 4), torch.randn(3, 4)], level="node")
batch.add_key("temperature", [torch.tensor([[300.0]]), torch.tensor([[350.0]])], level="system")
batch.add_key("edge_attr", [torch.randn(1, 4), torch.randn(2, 4)], level="edge")
batch.add_key("node_feat", new_values, level="node", overwrite=True)
batch.append(other_batch)
batch.append_data([more_atomic_data])
Pre-allocated buffer operations
For high-throughput workflows (e.g. streaming dynamics), use pre-allocated buffers:
buffer = Batch.empty(num_systems=40, num_nodes=80, num_edges=80, template=data)
mask = torch.tensor([True, False])
copied_mask = torch.zeros(2, dtype=torch.bool)
dest_mask = torch.zeros(buffer.system_capacity, dtype=torch.bool)
buffer.put(src_batch, mask, copied_mask=copied_mask, dest_mask=dest_mask)
src_batch.defrag(copied_mask=copied_mask)
buffer.zero()
Device, clone, memory
batch.to("cuda")
batch.cpu()
batch.cuda()
batch.clone()
batch.contiguous()
batch.pin_memory()
Serialization
batch.model_dump()
batch.model_dump(exclude_none=True)
batch.model_dump_json()
Distributed communication
Batch supports point-to-point distributed communication via
torch.distributed. Data is sent in three phases: a metadata header
(num_graphs, num_nodes, num_edges), per-group segment lengths,
and bulk tensor data.
Blocking send/recv:
import torch.distributed as dist
batch.send(dst=1, tag=0, group=None)
received = Batch.recv(src=0, device="cuda", template=template_batch, tag=0)
Non-blocking send/recv:
handle = batch.isend(dst=1, tag=0, group=None)
handle.wait()
handle = Batch.irecv(src=0, device="cuda", template=template_batch, tag=0)
received = handle.wait()
Key details:
template is required on the receiver to know the attribute keys,
dtypes, and group structure (atoms/edges/system). Cache it across calls.
- A 0-graph sentinel batch can be sent or received. Only the metadata
header is transmitted.
tag is a base tag incremented internally per group. Use distinct
base tags for concurrent send/recv pairs.
empty_like(batch) creates a 0-graph batch with the same schema, which
is useful for sentinel signals.
sentinel = Batch.empty_like(batch, device="cuda")
sentinel.send(dst=1)
Round-trip
reconstructed = batch.to_data_list()
batch_again = Batch.from_data_list(reconstructed)