File size: 12,248 Bytes
fefb9a6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90b47cf
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
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
"""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)