File size: 10,937 Bytes
fefb9a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
"""
dft8k_reader.py - reader/decoder for the dft8k HDF5 dataset.

DFT8k is an external NMR shielding benchmark of 7111 organic molecules (Guan, Paton and
co-workers; reference 14 in the paper), used to evaluate the MagNET models out of sample.
The file has TWO groups, one per level of theory. Both groups cover the SAME 7111 molecules
and use the SAME integer molecule ids, but in different row order, so match a molecule across
the two groups by id (use `molecule_by_id`), not by row position.

  /aimnet2_wp04_wb97xd_pcsseg2   AIMNet2 geometries; per atom, three DFT shieldings plus the
                                 two MagNET predictions. This is the file behind the paper's
                                 DFT8k panels.
  /b3lyp_pbe0_pcsseg1            B3LYP/pcSseg-1 geometries; three geometries per molecule with
                                 one PBE0/pcSseg-1 shielding each, plus symmetry-equivalence
                                 groups and per-atom averaging weights. This is the format the
                                 model's own training/eval loader consumes.

Storage format:
  - Per-atom arrays are concatenated across the molecules in a group. Each molecule's atom
    count is in `n_atoms`; molecule i owns the rows [start[i], start[i+1]) where start is the
    cumulative sum of `n_atoms` (reconstructed once, on open).
  - coordinates and shieldings are int32 fixed-point: physical = stored * scale (scale = 1e-4),
    so reconstruction error is <= 5e-5 (Angstrom or ppm). A missing value (an atom a model does
    not predict, or an absent shielding) is the marker -2147483648 and decodes to NaN.
  - The molecule's integer `molecule_ids` entry is its identifier.

Requires: Python >= 3.7, numpy, h5py. No other dependencies.
"""
from __future__ import annotations
import numpy as np
import h5py

_SCALE = 1e-4
_MARKER = -2147483648


def _decode(values: np.ndarray) -> np.ndarray:
    """int32 fixed-point -> float64 physical units; marker -> NaN. No-op on float input."""
    values = np.asarray(values)
    if not np.issubdtype(values.dtype, np.integer):
        return np.asarray(values, dtype=np.float64)
    out = values.astype(np.float64) * _SCALE
    out[values == _MARKER] = np.nan
    return out


class _Group:
    """Common molecule-indexing logic shared by both groups."""

    def __init__(self, grp: h5py.Group):
        self.g = grp
        self.name = grp.name.lstrip("/")
        self.molecule_ids = grp["molecule_ids"][:]
        self.n_molecules = int(grp.attrs["n_molecules"])
        self.n_atoms = int(grp.attrs["n_atoms"])
        self.level_of_theory = str(grp.attrs["level_of_theory"])
        self.geometry = str(grp.attrs["geometry"])
        na = grp["n_atoms"][:].astype(np.int64)
        self._start = np.empty(self.n_molecules + 1, np.int64)
        self._start[0] = 0
        np.cumsum(na, out=self._start[1:])
        self._id_to_index = {int(m): i for i, m in enumerate(self.molecule_ids)}

    def _check(self, index: int):
        if not 0 <= index < self.n_molecules:
            raise IndexError(f"molecule index {index} out of range [0, {self.n_molecules})")

    def _rows(self, index: int) -> slice:
        return slice(int(self._start[index]), int(self._start[index + 1]))

    def index_of(self, molecule_id: int) -> int:
        """Return the row position of a molecule id within this group."""
        if int(molecule_id) not in self._id_to_index:
            raise KeyError(f"molecule id {molecule_id} not in group {self.name}")
        return self._id_to_index[int(molecule_id)]

    def molecule_by_id(self, molecule_id: int) -> dict:
        """Look up a molecule by its id instead of its row position."""
        return self.molecule(self.index_of(molecule_id))

    def __len__(self):
        return self.n_molecules


class AIMNet2Group(_Group):
    """/aimnet2_wp04_wb97xd_pcsseg2 - AIMNet2 geometries, DFT and MagNET shieldings per atom."""

    def __init__(self, grp: h5py.Group):
        super().__init__(grp)
        # one SMILES per molecule. 6780 of 7111 are real; the rest are the literal "none".
        # Older files may lack the field, so guard for it.
        self._smiles = grp["smiles"].asstr()[:] if "smiles" in grp else None

    def smiles(self, index: int) -> str:
        """Return molecule i's SMILES string (the literal "none" if it was not provided)."""
        self._check(index)
        if self._smiles is None:
            return "none"
        return str(self._smiles[index])

    def molecule(self, index: int) -> dict:
        """Return one molecule's atom data (physical units) by position.

        Keys: id, smiles; atomic_numbers, coordinates (n,3 Angstrom); the three DFT shieldings
        (pbe0_pcSseg1, wp04_pcSseg2, wb97xd_pcSseg2; each (n,) ppm); and the two MagNET
        predictions (nn_wp04_pcSseg2, nn_wb97xd_pcSseg2; (n,) ppm). A MagNET prediction is
        NaN at atoms that model does not predict (WP04 is the 1H model, wB97X-D the 13C).
        """
        self._check(index)
        sl = self._rows(index)
        return {
            "id": int(self.molecule_ids[index]),
            "smiles": self.smiles(index),
            "atomic_numbers": self.g["atomic_numbers"][sl],
            "coordinates": _decode(self.g["coordinates"][sl]),
            "pbe0_pcSseg1": _decode(self.g["shielding_pbe0_pcSseg1"][sl]),
            "wp04_pcSseg2": _decode(self.g["shielding_wp04_pcSseg2"][sl]),
            "wb97xd_pcSseg2": _decode(self.g["shielding_wb97xd_pcSseg2"][sl]),
            "nn_wp04_pcSseg2": _decode(self.g["nn_shielding_wp04_pcSseg2"][sl]),
            "nn_wb97xd_pcSseg2": _decode(self.g["nn_shielding_wb97xd_pcSseg2"][sl]),
        }

    def all_shieldings(self) -> dict:
        """Every atom in the dataset as flat arrays (physical units), for whole-dataset analyses
        like the MagNET-vs-DFT residual histograms.

        Keys: atomic_numbers (n_atoms_total,); the three DFT shieldings (pbe0_pcSseg1,
        wp04_pcSseg2, wb97xd_pcSseg2) and the two MagNET predictions (nn_wp04_pcSseg2,
        nn_wb97xd_pcSseg2), each (n_atoms_total,) ppm. A MagNET prediction is NaN at atoms that
        model does not predict (WP04 is the 1H model, wB97X-D the 13C). To get one nucleus' residual,
        difference the DFT and MagNET column for that model at that element and drop the NaNs.
        """
        return {
            "atomic_numbers": self.g["atomic_numbers"][:],
            "pbe0_pcSseg1": _decode(self.g["shielding_pbe0_pcSseg1"][:]),
            "wp04_pcSseg2": _decode(self.g["shielding_wp04_pcSseg2"][:]),
            "wb97xd_pcSseg2": _decode(self.g["shielding_wb97xd_pcSseg2"][:]),
            "nn_wp04_pcSseg2": _decode(self.g["nn_shielding_wp04_pcSseg2"][:]),
            "nn_wb97xd_pcSseg2": _decode(self.g["nn_shielding_wb97xd_pcSseg2"][:]),
        }

    def __repr__(self):
        return (f"<AIMNet2Group /{self.name}: {self.n_molecules:,} molecules, "
                f"{self.n_atoms:,} atoms>")


class B3LYPGroup(_Group):
    """/b3lyp_pbe0_pcsseg1 - three geometries per molecule, symmetry groups and weights."""

    def __init__(self, grp: h5py.Group):
        super().__init__(grp)
        self.n_geometries = int(grp.attrs["n_geometries"])
        # symmetry groups are stored flat: n_groups per molecule, each group's length, then
        # all member atom indices. Reconstruct the per-molecule offsets once, on open.
        self._sym_n = grp["symmetric_atoms_n_groups"][:].astype(np.int64)
        self._sym_len = grp["symmetric_atoms_group_lengths"][:].astype(np.int64)
        self._sym_mem = grp["symmetric_atoms_members"][:]
        self._group_start = np.empty(self.n_molecules + 1, np.int64)
        self._group_start[0] = 0
        np.cumsum(self._sym_n, out=self._group_start[1:])
        self._member_start = np.empty(self._sym_len.shape[0] + 1, np.int64)
        self._member_start[0] = 0
        np.cumsum(self._sym_len, out=self._member_start[1:])

    def symmetric_atoms(self, index: int) -> list:
        """Return molecule i's symmetry-equivalence groups as a list of lists of atom indices.

        The indices are 0-based positions into this molecule's atom arrays (atomic_numbers,
        coordinates), so a group can be used to index those arrays directly.
        """
        self._check(index)
        groups = []
        for gi in range(int(self._group_start[index]), int(self._group_start[index + 1])):
            a, b = int(self._member_start[gi]), int(self._member_start[gi + 1])
            groups.append([int(x) for x in self._sym_mem[a:b]])
        return groups

    def molecule(self, index: int) -> dict:
        """Return one molecule's data (physical units) by position.

        Keys: id, atomic_numbers; coordinates (n_geometries, n, 3 Angstrom); shielding
        (n_geometries, n, ppm), where geometry 0 is a reference and 1, 2 are perturbations;
        weights (n,) per-atom symmetry-averaging weight; symmetric_atoms (list of lists of
        equivalent atoms, each a 0-based index into this molecule's atom arrays).
        """
        self._check(index)
        sl = self._rows(index)
        return {
            "id": int(self.molecule_ids[index]),
            "atomic_numbers": self.g["atomic_numbers"][sl],
            "coordinates": _decode(self.g["coordinates"][:, sl, :]),
            "shielding": _decode(self.g["shielding"][:, sl]),
            "weights": self.g["weights"][sl].astype(np.float64),
            "symmetric_atoms": self.symmetric_atoms(index),
        }

    def __repr__(self):
        return (f"<B3LYPGroup /{self.name}: {self.n_molecules:,} molecules, "
                f"{self.n_atoms:,} atoms, {self.n_geometries} geometries>")


class DFT8k:
    """Reader for dft8k.hdf5. Use as a context manager; access the two groups by attribute."""

    def __init__(self, path: str):
        self.f = h5py.File(path, "r")
        self.aimnet2 = AIMNet2Group(self.f["aimnet2_wp04_wb97xd_pcsseg2"])
        self.b3lyp = B3LYPGroup(self.f["b3lyp_pbe0_pcsseg1"])

    def close(self):
        """Close the underlying HDF5 file."""
        self.f.close()

    def __enter__(self):
        return self

    def __exit__(self, *a):
        self.close()

    def __repr__(self):
        return f"<DFT8k: {self.aimnet2!r}, {self.b3lyp!r}>"


if __name__ == "__main__":
    import sys
    path = sys.argv[1] if len(sys.argv) > 1 else "dft8k.hdf5"
    with DFT8k(path) as ds:
        print(ds)
        m = ds.aimnet2.molecule(0)
        h = m["atomic_numbers"] == 1
        print(f"  aimnet2 mol id={m['id']}: {len(m['atomic_numbers'])} atoms  smiles={m['smiles']}")
        print(f"    1H WP04 DFT[:3]={np.round(m['wp04_pcSseg2'][h][:3], 3)}  "
              f"MagNET[:3]={np.round(m['nn_wp04_pcSseg2'][h][:3], 3)}")
        b = ds.b3lyp.molecule(0)
        print(f"  b3lyp   mol id={b['id']}: coordinates {b['coordinates'].shape}, "
              f"{len(b['symmetric_atoms'])} symmetry groups")