MagNET / data /applications /applications_reader.py
ekwan16's picture
sync code from github@d2dbab26ef57a01a14fbe811b084a3716af12e52
90b47cf verified
Raw
History Blame Contribute Delete
12.2 kB
"""Loader for the natural-products ("applications") release.
applications.hdf5 stores shieldings and coordinates as whole numbers equal to the real value
times 10,000 (scale 1e-4), with -2147483648 marking values that were never computed. Energies
are stored as ordinary floating-point numbers. This module turns the stored numbers back into
real values (leaving blanks where data is missing) and exposes one accessor per data layer, so
notebooks never touch the raw file format. It is the single place where decoding happens.
Solute names: vomicine, prednisone, peptide, flavone, dihydrotanshinone_I, and
isomer_1E/1Z/2E/2Z/3E/3Z/4N/4O.
Solvents: TIP4P (water), benzene, chloroform, methanol.
"""
import h5py
import io
import os
import numpy as np
import pandas as pd
_SCALE = 1e4
_MISSING_MARKER = -2147483648
SOLVENTS = ["TIP4P", "benzene", "chloroform", "methanol"]
# shell label = approximate number of heavy (non-hydrogen) atoms in the solute-plus-solvent cluster
SHELL_SIZES = [50, 150, 250, 350, 450, 550, 650]
def _decode(values):
"""Turn stored whole numbers back into real values; blanks where data is missing."""
values = np.asarray(values)
if np.issubdtype(values.dtype, np.integer):
out = values.astype(np.float64) / _SCALE
out[values == _MISSING_MARKER] = np.nan
return out
return np.asarray(values, dtype=np.float64)
class Applications:
"""Read-only accessor for applications.hdf5 (+ the experimental spreadsheet)."""
def __init__(self, hdf5_path, experimental_xlsx=None, md_geometries_path=None):
self.hdf5_path = str(hdf5_path)
self.experimental_xlsx = str(experimental_xlsx) if experimental_xlsx else None
# the large MD-geometry companion is optional; default to the sibling file if present
if md_geometries_path is None:
sibling = self.hdf5_path.replace("applications.hdf5", "applications_md_geometries.hdf5")
md_geometries_path = sibling if (sibling != self.hdf5_path and os.path.exists(sibling)) else None
self.md_geometries_path = str(md_geometries_path) if md_geometries_path else None
def _open(self):
return h5py.File(self.hdf5_path, "r")
def _open_md(self):
if not self.md_geometries_path:
raise ValueError(
"MD-geometry companion not available. Download applications_md_geometries.hdf5 "
"next to applications.hdf5, or pass md_geometries_path=...")
return h5py.File(self.md_geometries_path, "r")
# ---- structure ----
_NON_SOLUTE_GROUPS = ("solvents", "composite_model")
def solutes(self):
"""List the solute names stored in the release."""
with self._open() as f:
return [k for k in f.keys() if k not in self._NON_SOLUTE_GROUPS]
def atomic_numbers(self, solute):
"""Return a solute's per-atom atomic numbers."""
with self._open() as f:
return np.asarray(f[solute]["atomic_numbers"][()])
# ---- MagNET-Zero (gas) + implicit-solvent (PCM) ----
def magnet_zero(self, solute):
"""Boltzmann-weighted MagNET-Zero stationary shieldings and the PCM correction."""
with self._open() as f:
g = f[solute]["magnet_zero"]
return {
"stationary": _decode(g["stationary"][()]),
"pcm_correction": _decode(g["pcm_correction"][()]),
}
def magnet_zero_conformers(self, solute):
"""Per-conformer dict: {conformer_name: {energy, geometry, magnet_zero, pcm}}."""
out = {}
with self._open() as f:
cg = f[solute]["magnet_zero"]["conformers"]
for ck in cg.keys():
c = cg[ck]
out[ck] = {
"energy": float(c["energy"][()]),
"geometry": _decode(c["geometry"][()]),
"atomic_numbers": np.asarray(c["atomic_numbers"][()]),
"magnet_zero": _decode(c["magnet_zero"][()]),
"pcm": _decode(c["pcm"][()]),
}
return out
# ---- QCD (rovibrational) ----
def qcd(self, solute, shielding_only=False):
"""qcd stationary (n_atoms,4)=[x,y,z,shielding] and trajectories (replica,frame,n_atoms,4).
With shielding_only=True returns just the shielding channel (column 3)."""
with self._open() as f:
g = f[solute]["qcd"]
stat = _decode(g["stationary"][()])
traj = _decode(g["trajectories"][()])
if shielding_only:
return {"stationary": stat[..., 3], "trajectories": traj[..., 3]}
return {"stationary": stat, "trajectories": traj}
# ---- MagNET-X (explicit solvent) ----
def magnet_x(self, solute, solvent, shell):
"""Explicit-solvent shieldings (frames, n_atoms, 2)=[isolated, solvated] for one shell."""
with self._open() as f:
return _decode(f[solute]["magnet_x"][solvent][f"shell_{shell}"][()])
def magnet_x_stationary(self, solute, solvent):
"""Explicit-solvent stationary-geometry shieldings for one solute/solvent pair."""
with self._open() as f:
return _decode(f[solute]["magnet_x"][solvent]["stationary"][()])
def available_shells(self, solute, solvent):
"""List the shell sizes stored for one solute/solvent pair."""
with self._open() as f:
return sorted(
int(k.split("_")[1])
for k in f[solute]["magnet_x"][solvent].keys()
if k.startswith("shell_")
)
# ---- DFT reference (provided-only; PBE0(PBE1PBE)/pcSseg-3 GIAO, PCM per solvent) ----
def dft_reference(self, solute, solvent):
"""Reference DFT shieldings for one solvent. Solvent keys here are 'gas', 'water',
'benzene', 'chloroform', 'methanol' -- note 'water' is the CPCM continuum-water model
(not the explicit 'TIP4P' key used by magnet_x), and 'gas' has no MagNET-x counterpart."""
with self._open() as f:
return _decode(f[solute]["dft_reference"][solvent]["shieldings"][()])
def dft_reference_geometries(self, solute):
"""The DFT conformer geometries the reference shieldings were computed on, as a dict
{conformer_name: {coordinates (n_atoms, 3), atomic_numbers (n_atoms,), energy}}."""
out = {}
with self._open() as f:
g = f[solute]["dft_reference"].get("geometries")
if g is None:
return out
for ck in g.keys():
c = g[ck]
out[ck] = {
"coordinates": _decode(c["coordinates"][()]),
"atomic_numbers": np.asarray(c["atomic_numbers"][()]),
"energy": float(c["energy"][()]),
}
return out
# ---- composite-model coefficients (fit on delta-22, applied to the natural products) ----
def _composite_csv(self, *path):
key = "composite_model/" + "/".join(path)
with self._open() as f:
if key not in f:
raise KeyError(f"{key} not in {self.hdf5_path}")
txt = f[key][()]
if isinstance(txt, bytes):
txt = txt.decode("utf-8")
return pd.read_csv(io.StringIO(txt))
def ols_coefficients(self, formula, nucleus):
"""OLS composite-model coefficients (parameter rows x solvent columns) for one formula/nucleus."""
return self._composite_csv("ols_coefficients", formula, nucleus)
def bootstrap_coefficients(self, formula, nucleus):
"""Bootstrap-resampled composite-model coefficients (one row per solvent x seed)."""
return self._composite_csv("bootstrap_coefficients", formula, nucleus)
def rmse_distribution(self, nucleus):
"""delta-22 bootstrap RMSE distributions (solvent, nucleus, formula, seed, Bootstrap_RMSE)."""
return self._composite_csv("rmse_distributions", nucleus)
def pcm_conversion_factors(self, nucleus):
"""Per-solvent scale factors applied to the chloroform-only PCM correction."""
return self._composite_csv("pcm_conversion_factors", nucleus)
def composite_formulas(self, kind="ols_coefficients"):
"""List the formula keys available under a composite_model subgroup."""
with self._open() as f:
g = f.get(f"composite_model/{kind}")
return sorted(g.keys()) if g is not None else []
# ---- canonical solvent atom ordering ----
def solvent_atomic_numbers(self, solvent):
"""Return one solvent molecule's atomic numbers, in the canonical atom order."""
with self._open() as f:
return np.asarray(f["solvents"][solvent]["atomic_numbers"][()])
# ---- MD solvent-shell geometries (the large companion file) ----
def available_md_shells(self, solute, solvent):
"""List the shell sizes with MD geometries for one solute/solvent pair."""
with self._open_md() as f:
return sorted(int(k.split("_")[1]) for k in f[solute][solvent].keys()
if k.startswith("geometries_"))
def md_geometry(self, solute, solvent, shell):
"""Solvent-shell snapshot coordinates (frames, n_shell_atoms, 3) for one shell. The frame
axis aligns 1:1 with magnet_x(solute, solvent, shell)."""
with self._open_md() as f:
return _decode(f[solute][solvent][f"geometries_{shell}"][()])
def md_geometry_atomic_numbers(self, solute, solvent, shell):
"""Atomic numbers for one MD solvent-shell snapshot's atoms."""
with self._open_md() as f:
return np.asarray(f[solute][solvent][f"atomic_numbers_{shell}"][()])
def md_stationary_geometry(self, solute):
"""The solute's stationary-geometry coordinates from the OpenMM setup."""
with self._open_md() as f:
return _decode(f[solute]["openMM_stationary_geometry"][()])
# ---- experiment ----
@staticmethod
def _prefix_h_sites(df):
"""Give each proton site a unique label per solute. The spreadsheet labels every H
site simply "H"; this prepends an incrementing two-digit number in row order per
solute ("01 H", "02 H", ...), matching the figure notebooks so per-site H rows are
distinguishable. Carbon sites already carry distinct labels and are left untouched."""
df = df.copy()
for solute in df["solute"].unique():
n = 1
mask = (df["solute"] == solute) & (df["nucleus"] == "H")
for idx in df[mask].index:
df.at[idx, "site"] = f"{n:02d} {df.at[idx, 'site']}"
n += 1
return df
def experiment(self, rename_water_to_tip4p=True, uuid_prefix_h_sites=False):
"""Flat long table from the per-solute-sheet xlsx: one row per (solute, site).
With uuid_prefix_h_sites=True, proton sites are made unique per solute (see
_prefix_h_sites) as the figure notebooks require."""
if not self.experimental_xlsx:
raise ValueError("experimental_xlsx path not provided")
sheets = pd.read_excel(self.experimental_xlsx, sheet_name=None)
frames = []
for solute, df in sheets.items():
df = df.copy()
df.insert(0, "solute", solute)
frames.append(df)
out = pd.concat(frames, ignore_index=True)
if rename_water_to_tip4p and "water" in out.columns:
out = out.rename(columns={"water": "TIP4P"})
if uuid_prefix_h_sites:
out = self._prefix_h_sites(out)
return out
def site_atom_table(self, uuid_prefix_h_sites=False):
"""Mapping of each (solute, nucleus, site) to its atom_numbers, used to aggregate
per-atom shieldings up to NMR sites. `atom_numbers` is returned as the raw string from
the spreadsheet (e.g. "17,19" or "21"); callers split/offset it themselves (atoms are
1-based in the spreadsheet, so subtract 1 to index the shielding arrays)."""
exp = self.experiment(rename_water_to_tip4p=False, uuid_prefix_h_sites=uuid_prefix_h_sites)
cols = ["solute", "site", "nucleus", "atom_numbers"]
return exp[cols].drop_duplicates().reset_index(drop=True)