kbkit 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kbkit/__init__.py +5 -0
- kbkit/_version.py +1 -0
- kbkit/analysis/__init__.py +7 -0
- kbkit/analysis/kb_integrator.py +283 -0
- kbkit/analysis/kb_thermo.py +876 -0
- kbkit/analysis/system_state.py +381 -0
- kbkit/calculators/__init__.py +6 -0
- kbkit/calculators/kbi_calculator.py +268 -0
- kbkit/calculators/static_structure_calculator.py +415 -0
- kbkit/config/__init__.py +6 -0
- kbkit/config/mplstyle.py +18 -0
- kbkit/config/presentation.mplstyle +48 -0
- kbkit/config/unit_registry.py +82 -0
- kbkit/core/__init__.py +7 -0
- kbkit/core/kb_pipeline.py +196 -0
- kbkit/core/system_loader.py +352 -0
- kbkit/core/system_properties.py +222 -0
- kbkit/core/system_registry.py +136 -0
- kbkit/data/__init__.py +5 -0
- kbkit/data/gmx_units.json +12 -0
- kbkit/data/property_resolver.py +101 -0
- kbkit/parsers/__init__.py +8 -0
- kbkit/parsers/edr_file.py +265 -0
- kbkit/parsers/gro_file.py +88 -0
- kbkit/parsers/rdf_file.py +259 -0
- kbkit/parsers/top_file.py +126 -0
- kbkit/schema/kbi_metadata.py +49 -0
- kbkit/schema/plot_spec.py +39 -0
- kbkit/schema/system_config.py +51 -0
- kbkit/schema/system_metadata.py +49 -0
- kbkit/schema/thermo_property.py +77 -0
- kbkit/schema/thermo_state.py +121 -0
- kbkit/utils/__init__.py +5 -0
- kbkit/utils/chem.py +53 -0
- kbkit/utils/file_resolver.py +123 -0
- kbkit/utils/format.py +82 -0
- kbkit/utils/io.py +54 -0
- kbkit/utils/logging.py +35 -0
- kbkit/utils/validation.py +61 -0
- kbkit/viz/__init__.py +5 -0
- kbkit/viz/plotter.py +653 -0
- kbkit-1.0.0.dist-info/METADATA +217 -0
- kbkit-1.0.0.dist-info/RECORD +45 -0
- kbkit-1.0.0.dist-info/WHEEL +4 -0
- kbkit-1.0.0.dist-info/licenses/LICENSE +21 -0
kbkit/__init__.py
ADDED
kbkit/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.0.0"
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Computes Kirkwood-Buff integrals (KBIs) from RDF data and applies thermodynamic limit corrections.
|
|
3
|
+
|
|
4
|
+
Relies on RDF parsing and system composition data to produce corrected KBIs for use in thermodynamic models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
import numpy as np
|
|
13
|
+
from numpy.typing import NDArray
|
|
14
|
+
from scipy.integrate import cumulative_trapezoid
|
|
15
|
+
|
|
16
|
+
from kbkit.core.system_properties import SystemProperties
|
|
17
|
+
from kbkit.parsers.rdf_file import RDFParser
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class KBIntegrator:
|
|
21
|
+
"""
|
|
22
|
+
Class to compute the Kirkwood-Buff Integrals (KBI) from RDF data.
|
|
23
|
+
|
|
24
|
+
Parameters
|
|
25
|
+
----------
|
|
26
|
+
rdf_file : str
|
|
27
|
+
Path to the RDF file containing radial distances and corresponding g(r) values.
|
|
28
|
+
use_fixed_rmin : bool
|
|
29
|
+
Whether to use a fixed minimum distance (rmin) for analysis.
|
|
30
|
+
system_properties : SystemProperties
|
|
31
|
+
SystemProperties object containing information about the system, including topology and box dimensions.
|
|
32
|
+
|
|
33
|
+
Attributes
|
|
34
|
+
----------
|
|
35
|
+
rdf: RDFParser
|
|
36
|
+
RDFParser object for parsing RDF file.
|
|
37
|
+
system_properties: SystemProperties
|
|
38
|
+
SystemProperties object.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, rdf_file: str | Path, use_fixed_rmin: bool, system_properties: SystemProperties) -> None:
|
|
42
|
+
self.rdf = RDFParser(rdf_file=str(rdf_file), use_fixed_rmin=use_fixed_rmin)
|
|
43
|
+
self.system_properties = system_properties
|
|
44
|
+
|
|
45
|
+
def box_vol(self) -> float:
|
|
46
|
+
"""Return the volume of the system box in nm^3."""
|
|
47
|
+
vol = self.system_properties.get("volume", units="nm^3")
|
|
48
|
+
if isinstance(vol, tuple):
|
|
49
|
+
vol = vol[0]
|
|
50
|
+
return float(vol)
|
|
51
|
+
|
|
52
|
+
def rdf_molecules(self) -> list[str]:
|
|
53
|
+
"""Get the molecules corresponding to the RDF file from the system topology.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
list
|
|
58
|
+
List of molecule IDs corresponding to the RDF file.
|
|
59
|
+
"""
|
|
60
|
+
# extract molecules from file name and topology information
|
|
61
|
+
rdf_mols = RDFParser.extract_mols(Path(self.rdf.rdf_file).name, self.system_properties.topology.molecules)
|
|
62
|
+
# check length of molecules found --- must be two for rdfs
|
|
63
|
+
N_RDF_MOLS = 2
|
|
64
|
+
if len(rdf_mols) != N_RDF_MOLS:
|
|
65
|
+
raise ValueError("Number of molecules corresponding to ID in .top file is not 2!")
|
|
66
|
+
return rdf_mols
|
|
67
|
+
|
|
68
|
+
def kd(self) -> int:
|
|
69
|
+
"""Return the Kronecker delta between molecules in RDF, i.e., determines if molecules :math:`i,j` are the same."""
|
|
70
|
+
return int(self.rdf_molecules()[0] == self.rdf_molecules()[1])
|
|
71
|
+
|
|
72
|
+
def n_j(self) -> int:
|
|
73
|
+
"""Return the number of molecule :math:`j` in the system."""
|
|
74
|
+
return self.system_properties.topology.molecule_count[self.rdf_molecules()[1]]
|
|
75
|
+
|
|
76
|
+
def g_gv(self) -> NDArray[np.float64]:
|
|
77
|
+
r"""
|
|
78
|
+
Compute the corrected pair distribution function, accounting for finite-size effects in the simulation box, based on the approach by `Ganguly and Van der Vegt (2013) <https://doi.org/10.1021/ct301017q>`_.
|
|
79
|
+
|
|
80
|
+
Returns
|
|
81
|
+
-------
|
|
82
|
+
np.ndarray
|
|
83
|
+
Corrected g(r) values as a numpy array corresponding to distances `r` from the RDF.
|
|
84
|
+
|
|
85
|
+
Notes
|
|
86
|
+
-----
|
|
87
|
+
The correction is calculated as
|
|
88
|
+
|
|
89
|
+
.. math::
|
|
90
|
+
v_r = 1 - \frac{\frac{4}{3} \pi r^3}{V}
|
|
91
|
+
|
|
92
|
+
.. math::
|
|
93
|
+
\rho_j = \frac{N_j}{V}
|
|
94
|
+
|
|
95
|
+
.. math::
|
|
96
|
+
\Delta N_j = \int_0^r 4 \pi r^2 \rho_j \bigl(g(r) - 1 \bigr) \, dr
|
|
97
|
+
|
|
98
|
+
.. math::
|
|
99
|
+
g_{GV}(r) = g(r) \cdot \frac{N_j v_r}{N_j v_r - \Delta N_j - \delta_{ij}}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
where:
|
|
103
|
+
- :math:`r` is the distance
|
|
104
|
+
- :math:`V` is the box volume
|
|
105
|
+
- :math:`N_j` is the number of particles of type \( j \)
|
|
106
|
+
- :math:`g(r)` is the raw radial distribution function
|
|
107
|
+
- :math:`\delta_{ij}` is a kronecker delta
|
|
108
|
+
|
|
109
|
+
.. note::
|
|
110
|
+
The cumulative integral :math:`\Delta N_j` is approximated numerically using the trapezoidal rule.
|
|
111
|
+
"""
|
|
112
|
+
# calculate the reduced volume
|
|
113
|
+
vr = 1 - ((4 / 3) * np.pi * self.rdf.r**3 / self.box_vol())
|
|
114
|
+
|
|
115
|
+
# get the number density for molecule j
|
|
116
|
+
rho_j = self.n_j() / self.box_vol()
|
|
117
|
+
|
|
118
|
+
# function to integrate over
|
|
119
|
+
f = 4.0 * np.pi * self.rdf.r**2 * rho_j * (self.rdf.g - 1)
|
|
120
|
+
Delta_Nj = cumulative_trapezoid(f, x=self.rdf.r, dx=self.rdf.r[1] - self.rdf.r[0])
|
|
121
|
+
Delta_Nj = np.append(Delta_Nj, Delta_Nj[-1])
|
|
122
|
+
|
|
123
|
+
# correct g(r) with GV correction
|
|
124
|
+
g_gv = self.rdf.g * self.n_j() * vr / (self.n_j() * vr - Delta_Nj - self.kd())
|
|
125
|
+
return np.asarray(g_gv) # make sure that an array is returned
|
|
126
|
+
|
|
127
|
+
def window(self) -> NDArray[np.float64]:
|
|
128
|
+
r"""
|
|
129
|
+
Apply cubic correction (or window weight) to the radial distribution function, which is useful for ensuring that the integral converges properly at larger distances, based on the method described by `Krüger et al. (2013) <https://doi.org/10.1021/jz301992u>`_.
|
|
130
|
+
|
|
131
|
+
Returns
|
|
132
|
+
-------
|
|
133
|
+
np.ndarray
|
|
134
|
+
Windowed weight for the RDF
|
|
135
|
+
|
|
136
|
+
Notes
|
|
137
|
+
-----
|
|
138
|
+
The windowed weight is defined as:
|
|
139
|
+
|
|
140
|
+
.. math::
|
|
141
|
+
w(r) = 4 \pi r^2 \left(1 - \left(\frac{r}{r_{max}}\right)^3\right)
|
|
142
|
+
|
|
143
|
+
where:
|
|
144
|
+
- :math:`r` is the radial distance
|
|
145
|
+
- :math:`r_{max}` is the maximum radial distance in the RDF
|
|
146
|
+
"""
|
|
147
|
+
w = 4 * np.pi * self.rdf.r**2 * (1 - (self.rdf.r / self.rdf.rmax) ** 3)
|
|
148
|
+
return np.asarray(w)
|
|
149
|
+
|
|
150
|
+
def h(self) -> NDArray[np.float64]:
|
|
151
|
+
r"""
|
|
152
|
+
Calculate correlation function h(r) from the corrected g(r) values.
|
|
153
|
+
|
|
154
|
+
Returns
|
|
155
|
+
-------
|
|
156
|
+
np.ndarray
|
|
157
|
+
Correlation function h(r) as a numpy array.
|
|
158
|
+
|
|
159
|
+
Notes
|
|
160
|
+
-----
|
|
161
|
+
The correlation function is defined as:
|
|
162
|
+
|
|
163
|
+
.. math::
|
|
164
|
+
h(r) = g_{GV}(r) - 1
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
return self.g_gv() - 1
|
|
168
|
+
|
|
169
|
+
def rkbi(self) -> NDArray[np.float64]:
|
|
170
|
+
r"""
|
|
171
|
+
Compute KBI as a function of radial distance between molecules :math:`i` and :math:`j`.
|
|
172
|
+
|
|
173
|
+
Returns
|
|
174
|
+
-------
|
|
175
|
+
np.ndarray
|
|
176
|
+
KBI values as a numpy array corresponding to distances :math:`r` from the RDF.
|
|
177
|
+
|
|
178
|
+
Notes
|
|
179
|
+
-----
|
|
180
|
+
The KBI is computed using the formula:
|
|
181
|
+
|
|
182
|
+
.. math::
|
|
183
|
+
G_{ij}(r) = \int_0^r h(r) w(r) dr
|
|
184
|
+
|
|
185
|
+
where:
|
|
186
|
+
- :math:`h(r)` is the correlation function
|
|
187
|
+
- :math:`w(r)` is the window function
|
|
188
|
+
- :math:`r` is the radial distance
|
|
189
|
+
|
|
190
|
+
.. note::
|
|
191
|
+
The integration is performed using the trapezoidal rule.
|
|
192
|
+
"""
|
|
193
|
+
rkbi_arr = cumulative_trapezoid(self.window() * self.h(), self.rdf.r, initial=0)
|
|
194
|
+
return np.asarray(rkbi_arr)
|
|
195
|
+
|
|
196
|
+
def lambda_ratio(self) -> NDArray[np.float64]:
|
|
197
|
+
r"""
|
|
198
|
+
Calculate length ratio (:math:`\lambda`) of the system based on the radial distances and the box volume.
|
|
199
|
+
|
|
200
|
+
Returns
|
|
201
|
+
-------
|
|
202
|
+
np.ndarray
|
|
203
|
+
Length ratio as a numpy array corresponding to distances :math:`r` from the RDF.
|
|
204
|
+
|
|
205
|
+
Notes
|
|
206
|
+
-----
|
|
207
|
+
The length ratio is defined as:
|
|
208
|
+
|
|
209
|
+
.. math::
|
|
210
|
+
\lambda = \left(\frac{\frac{4}{3} \pi r^3}{V}\right)^{1/3}
|
|
211
|
+
|
|
212
|
+
where:
|
|
213
|
+
- :math:`r` is the radial distance
|
|
214
|
+
- :math:`V` is the box volume
|
|
215
|
+
"""
|
|
216
|
+
Vr = (4 / 3) * np.pi * self.rdf.r**3 / self.box_vol()
|
|
217
|
+
return Vr ** (1 / 3)
|
|
218
|
+
|
|
219
|
+
def fit_kbi_inf(self) -> NDArray[np.float64]:
|
|
220
|
+
r"""
|
|
221
|
+
Fit a linear model to the product of the length ratio and the KBI values for extrapolation to thermodynamic limit.
|
|
222
|
+
|
|
223
|
+
Returns
|
|
224
|
+
-------
|
|
225
|
+
tuple
|
|
226
|
+
Tuple containing the slope and intercept of the linear fit, which represents the KBI at infinite distance.
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
.. note::
|
|
230
|
+
The KBI at infinite distance is estimated by fitting a linear model to the product of the length ratio and the KBI values, using only the radial distances that are within the specified range (rmin to rmax).
|
|
231
|
+
"""
|
|
232
|
+
# get x and y values to fit thermodynamic correction
|
|
233
|
+
lam = self.lambda_ratio() # characteristic length
|
|
234
|
+
lam_kbi = lam * self.rkbi() # length x KBI (r)
|
|
235
|
+
|
|
236
|
+
# fit linear regression to masked values
|
|
237
|
+
fit_params = np.polyfit(lam[self.rdf.r_mask], lam_kbi[self.rdf.r_mask], 1)
|
|
238
|
+
return fit_params # return fit
|
|
239
|
+
|
|
240
|
+
def integrate(self) -> float:
|
|
241
|
+
"""
|
|
242
|
+
Compute KBI in thermodynamic limit.
|
|
243
|
+
|
|
244
|
+
Returns
|
|
245
|
+
-------
|
|
246
|
+
float
|
|
247
|
+
KBI in the thermodynamic limit, which is the slope of the linear fit to the product
|
|
248
|
+
of the length ratio and the KBI values.
|
|
249
|
+
"""
|
|
250
|
+
return float(self.fit_kbi_inf()[0])
|
|
251
|
+
|
|
252
|
+
def plot(self, save_dir: Optional[str] = None) -> None:
|
|
253
|
+
"""Plot RDF and the running KBI fit to thermodynamic limit.
|
|
254
|
+
|
|
255
|
+
Parameters
|
|
256
|
+
----------
|
|
257
|
+
save_dir : str, optional
|
|
258
|
+
Directory to save the plot. If not provided, the plot will be displayed but not saved
|
|
259
|
+
"""
|
|
260
|
+
# get running kbi
|
|
261
|
+
rkbi = self.rkbi()
|
|
262
|
+
# parameters for thermo-limit extrapolation
|
|
263
|
+
lam = self.lambda_ratio()
|
|
264
|
+
lam_rkbi = lam * rkbi
|
|
265
|
+
# fits to thermo limit
|
|
266
|
+
fit_params = self.fit_kbi_inf()
|
|
267
|
+
lam_fit = lam[self.rdf.r_mask]
|
|
268
|
+
lam_rkbi_fit = np.polyval(fit_params, lam_fit)
|
|
269
|
+
|
|
270
|
+
fig, ax = plt.subplots(1, 2, figsize=(9, 4))
|
|
271
|
+
ax[0].plot(self.rdf.r, rkbi)
|
|
272
|
+
ax[0].set_xlabel("r / nm")
|
|
273
|
+
ax[0].set_ylabel("G$_{ij}$ / nm$^3$")
|
|
274
|
+
ax[1].plot(lam, lam_rkbi)
|
|
275
|
+
ax[1].plot(lam_fit, lam_rkbi_fit, ls="--", c="k", label=f"KBI: {fit_params[0]:.2g} nm$^3$")
|
|
276
|
+
ax[1].set_xlabel(r"$\lambda$")
|
|
277
|
+
ax[1].set_ylabel(r"$\lambda$ G$_{ij}$ / nm$^3$")
|
|
278
|
+
fig.suptitle(
|
|
279
|
+
f"KBI Analysis for system: {os.path.basename(self.system_properties.system_path)} {'-'.join(self.rdf_molecules())}"
|
|
280
|
+
)
|
|
281
|
+
if save_dir is not None:
|
|
282
|
+
plt.savefig(os.path.join(save_dir, self.rdf.rdf_file[:-4] + ".png"))
|
|
283
|
+
plt.show()
|