| """Provenance/build script for delta50.hdf5. |
| |
| Regenerates the shipped delta50.hdf5 from three raw sources (none of which are shipped, exactly as |
| for dft8k): |
| |
| 1. the previous delta50.hdf5, for the MagNET predictions already packaged into it |
| (nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm; these come from MagNET inference and are carried |
| forward unchanged), plus the atom order, geometries, and atomic numbers; |
| 2. a directory of Gaussian NMR output files, one per molecule, each with two link jobs at the |
| MagNET-Zero reference levels (WP04 for 1H, wB97X-D for 13C; pcSseg-2; gas), computed on the |
| stored AIMNet2 geometries. Parsed with cctk. These become the per-atom DFT reference |
| shieldings shielding_wp04_pcSseg2 and shielding_wb97xd_pcSseg2, so the MagNET-Zero-vs-DFT |
| residual (Table S3/S4-style, the DELTA50 outlier check in the paper) reproduces from the file. |
| 3. DELTA50_benchmark.xlsx from the DELTA50 Supporting Information (Cohen et al., Molecules 2023, |
| 28, 2449; reference 47; CC BY 4.0), for the experimental 1H and 13C chemical shifts. |
| |
| The experimental shifts are mapped onto this file's own atom order and stored per atom as |
| experimental_shift. DELTA50 numbers its atoms against independently built structures; those geometries |
| were recomputed here, so the numbering only sometimes lines up. Each molecule is mapped by DELTA50's |
| own atom numbering when that numbering is element-consistent and covers every H/C atom (47 of 50); |
| the three whose atom order differs (cyclohexanone, methyl acetate, 2-methyl-2-nitropropane) are |
| mapped by matching RDKit topological-symmetry classes to DELTA50 environments in order of the |
| DFT-predicted shift. The mapping is validated: coverage is 100% of H and C atoms, and the large |
| scaled-DFT-vs-experiment residuals are all chemically hard sp2 carbons (carbonyl, nitro, aromatic). |
| |
| Usage: |
| python3 build_delta50.py [SRC_HDF5] [OUT_DIR] [XLSX] [DST_HDF5] |
| |
| with the defaults below. Requires numpy, h5py, pandas, cctk, and rdkit. |
| """ |
| from __future__ import annotations |
| import os |
| import re |
| import sys |
| from collections import defaultdict |
|
|
| import numpy as np |
| import h5py |
|
|
| SCALE = 1e-4 |
| MARKER = -2147483648 |
|
|
| _HOME = os.path.expanduser("~") |
| _HERE = os.path.dirname(os.path.abspath(__file__)) |
| DEFAULT_SRC = os.path.join(_HERE, "delta50.hdf5") |
| DEFAULT_OUT_DIR = os.path.join(_HOME, "research", "magnet", "delta50", "delta50", |
| "output", "wp04_wb97xd_pcSseg2") |
| DEFAULT_XLSX = os.path.join(_HOME, "research", "magnet", "delta50", "DELTA50_benchmark.xlsx") |
|
|
| |
| |
| RENAMES = {"t-butyl nitrate": "2-methyl-2-nitropropane"} |
|
|
| |
| |
| NAME_ALIASES = { |
| "dmf": "n,n-dimethylformamide", "thf": "tetrahydrofuran", "dmac": "n,n-dimethylacetamide", |
| "2cyanopropane": "isobutyronitrile", "mtbe": "methyl t-butyl ether", |
| "cyclopentenone": "cyclopent-2-en-1-one", "cyclohexenone": "cyclohex-2-en-1-one", |
| "pbenzoquinone": "1,4-benzoquinone", "thp": "tetrahydropyran", |
| "33dimethyl": "t-butylethylene", "1butene": "t-butylethylene", |
| "tbutylnitrate": "2-methyl-2-nitropropane", |
| } |
|
|
|
|
| def _norm(s): |
| return re.sub(r"[^a-z0-9]", "", str(s).lower()) |
|
|
|
|
| def _encode(values): |
| v = np.asarray(values, dtype=np.float64) |
| finite = np.isfinite(v) |
| out = np.full(v.shape, MARKER, dtype=np.int64) |
| out[finite] = np.round(v[finite] / SCALE).astype(np.int64) |
| return out.astype(np.int32) |
|
|
|
|
| def _parse_atom_labels(s): |
| """DELTA50 atom-number strings: comma lists ("5,6,7"), ranges ("7-12"), singles, and mixtures.""" |
| out = [] |
| for tok in re.split(r"[,\s]+", str(s).strip()): |
| m = re.match(r"^(\d+)-(\d+)$", tok) |
| if m: |
| out += list(range(int(m.group(1)), int(m.group(2)) + 1)) |
| elif tok.isdigit(): |
| out.append(int(tok)) |
| return out |
|
|
|
|
| def load_predictions(src_hdf5): |
| with h5py.File(src_hdf5, "r") as f: |
| return { |
| "molecule_names": [str(n) for n in f["molecule_names"].asstr()[:]], |
| "n_atoms": f["n_atoms"][:].astype(np.int32), |
| "atomic_numbers": f["atomic_numbers"][:], |
| "coordinates": f["coordinates"][:], |
| "nn_magnet_zero": f["nn_magnet_zero"][:], |
| "nn_b3lyp": f["nn_b3lyp"][:], |
| "nn_b3lyp_pcm": f["nn_b3lyp_pcm"][:], |
| } |
|
|
|
|
| def load_dft_reference(out_dir, file_names, out_names, atomic_numbers, starts): |
| """Per-atom WP04 and wB97X-D isotropic shieldings from the Gaussian outputs, aligned to the |
| stored atom order (checked). Files are matched by `file_names` (the raw-input naming) but the |
| returned per-molecule dict is keyed by `out_names` (the canonical output naming). |
| Returns (wp04, wb97xd, per_molecule_shieldings).""" |
| import cctk |
| files = {_norm(f[len("delta50-"):-len(".out")]): os.path.join(out_dir, f) |
| for f in os.listdir(out_dir) if f.endswith(".out")} |
| |
| |
| reverse = {v: k for k, v in RENAMES.items()} |
|
|
| def _find(name): |
| for candidate in (name, reverse.get(name, name)): |
| if _norm(candidate) in files: |
| return files[_norm(candidate)] |
| raise KeyError(f"no Gaussian output for {name!r} in {out_dir}") |
|
|
| wp04 = np.full(starts[-1], np.nan) |
| wb97xd = np.full(starts[-1], np.nan) |
| per_mol = {} |
| for i, (file_name, out_name) in enumerate(zip(file_names, out_names)): |
| objs = cctk.GaussianFile.read_file(_find(file_name)) |
| by_level = {} |
| for g in objs: |
| ens = g.ensemble |
| iso = np.asarray(ens.get_properties_dict(ens.molecules[-1])["isotropic_shielding"], |
| dtype=float) |
| z = np.asarray(ens.molecules[-1].atomic_numbers) |
| by_level["wb97xd" if "wb97xd" in g.route_card.lower() else "wp04"] = (z, iso) |
| sl = slice(int(starts[i]), int(starts[i + 1])) |
| z_ref = atomic_numbers[sl] |
| for level, (z, iso) in by_level.items(): |
| if not np.array_equal(z, z_ref): |
| raise ValueError(f"{out_name}: {level} atom order does not match the stored geometry") |
| wp04[sl] = by_level["wp04"][1] |
| wb97xd[sl] = by_level["wb97xd"][1] |
| per_mol[out_name] = {"wp04": by_level["wp04"][1], "wb97xd": by_level["wb97xd"][1]} |
| return wp04, wb97xd, per_mol |
|
|
|
|
| def _parse_experimental_sheets(xlsx, valid_names): |
| """{molecule_name: {"H": [(atoms, shift)], "C": [...]}} from the two DELTA50 functional sheets.""" |
| import pandas as pd |
| valid = {_norm(n): n for n in valid_names} |
| out = {n: {"H": [], "C": []} for n in valid_names} |
| for sheet, nucleus in (("1H Functional", "H"), ("13C Functional", "C")): |
| df = pd.read_excel(xlsx, sheet_name=sheet, header=None) |
| hdr = next(r for r in range(df.shape[0]) if str(df.iat[r, 0]).strip() == "Compound") |
| current = None |
| for r in range(hdr + 1, df.shape[0]): |
| compound, label, shift = df.iat[r, 0], df.iat[r, 1], df.iat[r, 2] |
| if isinstance(compound, str) and compound.strip(): |
| k = _norm(compound) |
| current = valid.get(_norm(NAME_ALIASES.get(k, compound))) |
| if current is None or pd.isna(label) or pd.isna(shift): |
| continue |
| out[current][nucleus].append((_parse_atom_labels(label), float(shift))) |
| return out |
|
|
|
|
| def _symmetry_classes(atomic_numbers, coordinates): |
| """RDKit topological-symmetry classes for one molecule: {rank: [atom indices]}. Bonds are |
| perceived from the geometry.""" |
| from rdkit import Chem |
| from rdkit.Chem import rdDetermineBonds |
| pt = Chem.GetPeriodicTable() |
| lines = [str(len(atomic_numbers)), ""] |
| for z, xyz in zip(atomic_numbers, coordinates): |
| lines.append(f"{pt.GetElementSymbol(int(z))} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}") |
| mol = Chem.MolFromXYZBlock("\n".join(lines)) |
| rdDetermineBonds.DetermineBonds(mol, charge=0) |
| classes = defaultdict(list) |
| for atom_idx, rank in enumerate(Chem.CanonicalRankAtoms(mol, breakTies=False)): |
| classes[rank].append(atom_idx) |
| return classes |
|
|
|
|
| def map_experimental(xlsx, molecule_names, atomic_numbers, coordinates, starts, dft_per_mol): |
| """Map DELTA50 experimental shifts onto our atom order; per-atom array (ppm, NaN where absent). |
| |
| Direct: use DELTA50's atom numbering where it is element-consistent and covers every H/C atom. |
| Fallback: for molecules whose atom order differs, match RDKit symmetry classes to DELTA50 |
| environments by DFT-predicted shift order. Asserts full H/C coverage.""" |
| experimental = np.full(starts[-1], np.nan) |
| fallback = [] |
| exp = _parse_experimental_sheets(xlsx, molecule_names) |
| for i, name in enumerate(molecule_names): |
| sl = slice(int(starts[i]), int(starts[i + 1])) |
| z = atomic_numbers[sl] |
| n = len(z) |
| envs = {1: exp[name]["H"], 6: exp[name]["C"]} |
| consistent = all(1 <= a <= n and z[a - 1] == zz |
| for zz in (1, 6) for atoms, _ in envs[zz] for a in atoms) |
| |
| |
| |
| direct = np.full(n, np.nan) |
| conflict = False |
| if consistent: |
| for zz in (1, 6): |
| for atoms, shift in envs[zz]: |
| for a in atoms: |
| if np.isfinite(direct[a - 1]) and direct[a - 1] != shift: |
| conflict = True |
| direct[a - 1] = shift |
| if consistent and not conflict and np.isfinite(direct[(z == 1) | (z == 6)]).all(): |
| experimental[sl] = direct |
| continue |
| |
| fallback.append(name) |
| classes = _symmetry_classes(z, coordinates[sl]) |
| assigned = np.full(n, np.nan) |
| sigma = {1: dft_per_mol[name]["wp04"], 6: dft_per_mol[name]["wb97xd"]} |
| for zz in (1, 6): |
| element_classes = [atoms for atoms in classes.values() if z[atoms[0]] == zz] |
| if len(element_classes) != len(envs[zz]): |
| raise ValueError(f"{name}: {len(element_classes)} symmetry classes vs " |
| f"{len(envs[zz])} DELTA50 environments for Z={zz}") |
| by_sigma = sorted(element_classes, key=lambda a: np.mean([sigma[zz][j] for j in a])) |
| by_shift = sorted(envs[zz], key=lambda e: -e[1]) |
| for atoms, (_, shift) in zip(by_sigma, by_shift): |
| for j in atoms: |
| assigned[j] = shift |
| experimental[sl] = assigned |
|
|
| hc = (atomic_numbers == 1) | (atomic_numbers == 6) |
| missing = int((~np.isfinite(experimental[hc])).sum()) |
| if missing: |
| raise ValueError(f"{missing} H/C atoms have no experimental shift") |
| return experimental, fallback |
|
|
|
|
| def build(src_hdf5, out_dir, xlsx, dst_hdf5): |
| pred = load_predictions(src_hdf5) |
| file_names = pred["molecule_names"] |
| names = [RENAMES.get(n, n) for n in file_names] |
| starts = np.zeros(len(names) + 1, np.int64) |
| np.cumsum(pred["n_atoms"], out=starts[1:]) |
|
|
| coords = pred["coordinates"].astype(np.float64) * SCALE |
| wp04, wb97xd, dft_per_mol = load_dft_reference(out_dir, file_names, names, |
| pred["atomic_numbers"], starts) |
| experimental, fallback = map_experimental(xlsx, names, pred["atomic_numbers"], coords, starts, |
| dft_per_mol) |
| print(f"experimental mapped by symmetry fallback: {fallback}") |
|
|
| string_dt = h5py.string_dtype("utf-8") |
| opts = dict(compression="gzip", shuffle=True) |
| with h5py.File(dst_hdf5, "w") as f: |
| f.attrs["dataset"] = "delta50" |
| f.attrs["description"] = ( |
| "The 50 small molecules of the DELTA50 benchmark (Cohen et al., Molecules 2023, 28, " |
| "2449; CC BY 4.0) on AIMNet2 geometries. Per-atom arrays: MagNET predictions, the DFT " |
| "reference shieldings MagNET-Zero targets (WP04 for 1H, wB97X-D for 13C, pcSseg-2, gas), " |
| "and the published experimental shifts mapped onto this atom order. The MagNET-Zero-vs-" |
| "DFT residual and the experiment comparison both reproduce from this file.") |
| f.attrs["n_molecules"] = len(names) |
| f.attrs["geometry"] = "AIMNet2" |
| f.attrs["scale"] = SCALE |
| f.attrs["missing_value_marker"] = MARKER |
| f.attrs["nn_magnet_zero"] = "MagNET-Zero: WP04 (1H) / wB97X-D (13C), pcSseg-2, 20-pass" |
| f.attrs["nn_b3lyp"] = "MagNET-PCM gas-phase component: B3LYP/pcSseg-2" |
| f.attrs["nn_b3lyp_pcm"] = "MagNET-PCM chloroform component: B3LYP/pcSseg-2 + chloroform PCM" |
| f.attrs["shielding_wp04_pcSseg2"] = "DFT reference: WP04/pcSseg-2 gas (the 1H MagNET-Zero level)" |
| f.attrs["shielding_wb97xd_pcSseg2"] = "DFT reference: wB97X-D/pcSseg-2 gas (the 13C MagNET-Zero level)" |
| f.attrs["experimental_shift"] = ( |
| "Experimental 1H/13C shift (ppm, CDCl3, TMS reference) from DELTA50 (Cohen et al., " |
| "Molecules 2023, 28, 2449; CC BY 4.0), mapped onto this atom order. NaN at atoms with no " |
| "reported shift (heteroatoms).") |
|
|
| f.create_dataset("molecule_names", data=np.array(names, dtype=object), dtype=string_dt) |
| f.create_dataset("n_atoms", data=pred["n_atoms"], **opts) |
| f.create_dataset("atomic_numbers", data=pred["atomic_numbers"], **opts) |
| f.create_dataset("coordinates", data=pred["coordinates"], **opts) |
| f.create_dataset("nn_magnet_zero", data=pred["nn_magnet_zero"], **opts) |
| f.create_dataset("nn_b3lyp", data=pred["nn_b3lyp"], **opts) |
| f.create_dataset("nn_b3lyp_pcm", data=pred["nn_b3lyp_pcm"], **opts) |
| f.create_dataset("shielding_wp04_pcSseg2", data=_encode(wp04), **opts) |
| f.create_dataset("shielding_wb97xd_pcSseg2", data=_encode(wb97xd), **opts) |
| f.create_dataset("experimental_shift", data=_encode(experimental), **opts) |
|
|
| return dst_hdf5 |
|
|
|
|
| if __name__ == "__main__": |
| args = sys.argv[1:] |
| src = args[0] if len(args) > 0 else DEFAULT_SRC |
| out_dir = args[1] if len(args) > 1 else DEFAULT_OUT_DIR |
| xlsx = args[2] if len(args) > 2 else DEFAULT_XLSX |
| dst = args[3] if len(args) > 3 else DEFAULT_SRC |
| print(f"src predictions : {src}") |
| print(f"DFT outputs : {out_dir}") |
| print(f"experimental : {xlsx}") |
| print(f"writing : {dst}") |
| build(src, out_dir, xlsx, dst) |
| print("done.") |
|
|