Skip to content

Commit 2d753c3

Browse files
committed
Update on 03/14/26 at 19:08:21
1 parent 1780f2c commit 2d753c3

7 files changed

Lines changed: 1259 additions & 1 deletion

polyprotic_pr_review.md

Lines changed: 985 additions & 0 deletions
Large diffs are not rendered by default.

pyMBE/pyMBE.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,10 @@ def formal_charge(particle_name):
557557
terms = [10.0 ** (-cumsum_pK[i] + (i + 1) * pH) for i in range(n)]
558558
denominator = 1.0 + sum(terms)
559559
numerator = sum((i + 1) * terms[i] for i in range(n))
560-
charge = psi * numerator / denominator
560+
if acidity == "acidic":
561+
charge = -numerator / denominator
562+
else:
563+
charge = n - numerator / denominator
561564
else:
562565
pka = entry["pka_value"]
563566
charge = psi / (1.0 + 10.0 ** (psi * (pH - pka)))

tests/compare_polyprotic_HH.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""
2+
Compare pyMBE's polyprotic Henderson-Hasselbalch implementation against:
3+
1. The analytical calculate_Z formula from pbs_simulations
4+
2. Ideal constant-pH simulation data from pbs_simulations sample_data
5+
6+
Produces comparison plots for monoprotic, diprotic, and triprotic acids.
7+
"""
8+
import sys
9+
import os
10+
import numpy as np
11+
import pandas as pd
12+
import matplotlib.pyplot as plt
13+
14+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
15+
import pyMBE
16+
17+
# Add pbs_simulations analysis to path
18+
PBS_ROOT = os.path.expanduser("~/ntnu/pbs_simulations")
19+
sys.path.insert(0, os.path.join(PBS_ROOT, "scripts"))
20+
from analysis import analyze_time_series
21+
22+
# ---------- Reference analytical formula from pbs_simulations ----------
23+
24+
def calculate_Z_reference(pK, pH_array):
25+
"""Exact polyprotic HH formula from pbs_simulations/scripts/post_processing.py"""
26+
pK = np.asarray(pK)
27+
pH_array = np.atleast_1d(pH_array).astype(float)
28+
y = pH_array[None, :] # (1, N_pH)
29+
i_range = np.arange(1, len(pK) + 1)[:, None] # (n, 1)
30+
cumsum_pK = np.cumsum(pK)[:, None] # (n, 1)
31+
fractions = 10.0 ** (-cumsum_pK + i_range * y)
32+
numerator = np.sum(i_range * fractions, axis=0)
33+
denominator = 1.0 + np.sum(fractions, axis=0)
34+
return -numerator / denominator
35+
36+
37+
def compute_sim_Z(sample_dir, n):
38+
"""Compute average charge Z from simulation sample data using block binning."""
39+
analyzed = analyze_time_series(path_to_datafolder=sample_dir,
40+
ignore_files=["analyzed_data.csv"])
41+
pH_vals = []
42+
Z_vals = []
43+
Z_err = []
44+
for idx in range(len(analyzed)):
45+
row = analyzed.iloc[idx]
46+
pH_val = float(row[("pH", "value")])
47+
n_ha = float(row[("mean", "n_ha")])
48+
Z_num = -sum(float(row[("mean", f"n_a{i+1}")]) * (i + 1) for i in range(n))
49+
Z_den = n_ha + sum(float(row[("mean", f"n_a{i+1}")]) for i in range(n))
50+
pH_vals.append(pH_val)
51+
Z_vals.append(Z_num / Z_den if Z_den > 0 else 0.0)
52+
order = np.argsort(pH_vals)
53+
return np.array(pH_vals)[order], np.array(Z_vals)[order]
54+
55+
56+
def compute_pyMBE_Z(pka_list, pH_array):
57+
"""Compute charge using pyMBE's calculate_HH."""
58+
pmb = pyMBE.pymbe_library(seed=42)
59+
n = len(pka_list)
60+
if n == 1:
61+
pmb.define_particle(name="acid",
62+
sigma=0.35 * pmb.units.nm,
63+
epsilon=1 * pmb.units("reduced_energy"),
64+
acidity="acidic",
65+
pka=pka_list[0])
66+
else:
67+
pmb.define_polyprotic_particle(name="acid",
68+
sigma=0.35 * pmb.units.nm,
69+
epsilon=1 * pmb.units("reduced_energy"),
70+
n=n,
71+
acidity="acidic",
72+
pka_list=pka_list)
73+
pmb.define_residue(name="res", central_bead="acid", side_chains=[])
74+
pmb.define_molecule(name="mol", residue_list=["res"])
75+
return pmb.calculate_HH(template_name="mol", pH_list=list(pH_array))
76+
77+
78+
# ---------- Configuration ----------
79+
80+
pK_values = {
81+
"monoprotic": [2.16],
82+
"diprotic": [2.16, 7.21],
83+
"triprotic": [2.16, 7.21, 12.32],
84+
}
85+
86+
sample_dirs = {
87+
"monoprotic": os.path.join(PBS_ROOT, "sample_data", "ideal-monoprotic"),
88+
"diprotic": os.path.join(PBS_ROOT, "sample_data", "ideal-diprotic"),
89+
"triprotic": os.path.join(PBS_ROOT, "sample_data", "ideal-triprotic"),
90+
}
91+
92+
# ---------- Plot ----------
93+
94+
fig, axes = plt.subplots(1, 3, figsize=(15, 5), sharey=True)
95+
96+
for ax, label in zip(axes, ["monoprotic", "diprotic", "triprotic"]):
97+
pK = pK_values[label]
98+
n = len(pK)
99+
pH_fine = np.linspace(0.5, 14.5, 200)
100+
101+
# 1. Reference analytical formula
102+
Z_ref = calculate_Z_reference(pK, pH_fine)
103+
104+
# 2. pyMBE calculate_HH
105+
Z_pyMBE = compute_pyMBE_Z(pK, pH_fine)
106+
107+
# 3. Simulation data
108+
pH_sim, Z_sim = compute_sim_Z(sample_dirs[label], n)
109+
110+
# Plot
111+
ax.plot(pH_fine, Z_ref, "k-", lw=2, label="Analytical (pbs_simulations)")
112+
ax.plot(pH_fine, Z_pyMBE, "r--", lw=2, label="pyMBE calculate_HH")
113+
ax.scatter(pH_sim, Z_sim, c="blue", s=30, zorder=5, label="Simulation data (ideal)")
114+
115+
# pKa markers
116+
for i, pk in enumerate(pK):
117+
ax.axvline(x=pk, color="gray", ls=":", alpha=0.6)
118+
ax.text(pk + 0.2, -n + 0.3, f"pK{i+1}={pk}", fontsize=8, color="gray")
119+
120+
ax.set_xlabel("pH")
121+
ax.set_title(f"{n}-protic acid")
122+
ax.grid(alpha=0.3)
123+
124+
# MSE between pyMBE and reference
125+
mse = np.mean((np.array(Z_pyMBE) - Z_ref) ** 2)
126+
ax.text(0.05, 0.05, f"MSE(pyMBE vs analytical) = {mse:.2e}",
127+
transform=ax.transAxes, fontsize=8,
128+
bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5))
129+
130+
axes[0].set_ylabel("Average charge Z")
131+
axes[0].legend(loc="lower left", fontsize=8)
132+
133+
plt.suptitle("Polyprotic HH: pyMBE vs Analytical vs Simulation", fontsize=14)
134+
plt.tight_layout()
135+
plt.savefig("tests/polyprotic_HH_comparison.png", dpi=150, bbox_inches="tight")
136+
print("Plot saved to tests/polyprotic_HH_comparison.png")
137+
plt.show()
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"""
2+
Compare pyMBE's polyprotic HH for basic particles against the analytical formula.
3+
No simulation data available for bases — analytical comparison only.
4+
"""
5+
import sys
6+
import os
7+
import numpy as np
8+
import matplotlib.pyplot as plt
9+
10+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
11+
import pyMBE
12+
13+
14+
def calculate_Z_base_reference(pK, pH_array):
15+
"""
16+
Analytical polyprotic HH for a basic particle.
17+
Fully protonated has charge +n, each deprotonation removes +1.
18+
Z = n - sum(i * 10^(-cumsum(pK)[i] + i*pH)) / (1 + sum(10^(-cumsum(pK)[i] + i*pH)))
19+
"""
20+
pK = np.asarray(pK)
21+
n = len(pK)
22+
pH_array = np.atleast_1d(pH_array).astype(float)
23+
y = pH_array[None, :]
24+
i_range = np.arange(1, n + 1)[:, None]
25+
cumsum_pK = np.cumsum(pK)[:, None]
26+
fractions = 10.0 ** (-cumsum_pK + i_range * y)
27+
numerator = np.sum(i_range * fractions, axis=0)
28+
denominator = 1.0 + np.sum(fractions, axis=0)
29+
return n - numerator / denominator
30+
31+
32+
def compute_pyMBE_Z_base(pka_list, pH_array):
33+
"""Compute charge using pyMBE's calculate_HH for a basic particle."""
34+
pmb = pyMBE.pymbe_library(seed=42)
35+
n = len(pka_list)
36+
if n == 1:
37+
pmb.define_particle(name="base",
38+
sigma=0.35 * pmb.units.nm,
39+
epsilon=1 * pmb.units("reduced_energy"),
40+
acidity="basic",
41+
pka=pka_list[0])
42+
else:
43+
pmb.define_polyprotic_particle(name="base",
44+
sigma=0.35 * pmb.units.nm,
45+
epsilon=1 * pmb.units("reduced_energy"),
46+
n=n,
47+
acidity="basic",
48+
pka_list=pka_list)
49+
pmb.define_residue(name="res", central_bead="base", side_chains=[])
50+
pmb.define_molecule(name="mol", residue_list=["res"])
51+
return pmb.calculate_HH(template_name="mol", pH_list=list(pH_array))
52+
53+
54+
pK_values = {
55+
"monoprotic": [9.25],
56+
"diprotic": [6.0, 10.0],
57+
"triprotic": [4.0, 8.0, 12.0],
58+
}
59+
60+
fig, axes = plt.subplots(1, 3, figsize=(15, 5), sharey=True)
61+
pH_fine = np.linspace(0.5, 14.5, 200)
62+
63+
for ax, label in zip(axes, ["monoprotic", "diprotic", "triprotic"]):
64+
pK = pK_values[label]
65+
n = len(pK)
66+
67+
Z_ref = calculate_Z_base_reference(pK, pH_fine)
68+
Z_pyMBE = compute_pyMBE_Z_base(pK, pH_fine)
69+
70+
ax.plot(pH_fine, Z_ref, "k-", lw=2, label="Analytical")
71+
ax.plot(pH_fine, Z_pyMBE, "r--", lw=2, label="pyMBE calculate_HH")
72+
73+
for i, pk in enumerate(pK):
74+
ax.axvline(x=pk, color="gray", ls=":", alpha=0.6)
75+
ax.text(pk + 0.2, 0.3, f"pK{i+1}={pk}", fontsize=8, color="gray")
76+
77+
ax.set_xlabel("pH")
78+
ax.set_title(f"{n}-protic base")
79+
ax.grid(alpha=0.3)
80+
81+
mse = np.mean((np.array(Z_pyMBE) - Z_ref) ** 2)
82+
ax.text(0.05, 0.05, f"MSE = {mse:.2e}",
83+
transform=ax.transAxes, fontsize=8,
84+
bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.5))
85+
86+
axes[0].set_ylabel("Average charge Z")
87+
axes[0].legend(loc="upper right", fontsize=8)
88+
89+
plt.suptitle("Polyprotic HH (basic): pyMBE vs Analytical", fontsize=14)
90+
plt.tight_layout()
91+
plt.savefig("tests/polyprotic_HH_comparison_base.png", dpi=150, bbox_inches="tight")
92+
print("Plot saved to tests/polyprotic_HH_comparison_base.png")
93+
plt.show()

tests/polyprotic_HH_comparison.png

115 KB
Loading
103 KB
Loading

testsuite/polyprotic_acidbase_tests.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,5 +300,45 @@ def test_auto_naming_convention(self):
300300
self.assertEqual(names, ["H4Y", "H3Y", "H2Y", "HY", "Y"])
301301

302302

303+
def test_calculate_HH_triprotic_acid(self):
304+
"""
305+
Test Henderson-Hasselbalch charge for a triprotic acid at extreme pH values.
306+
"""
307+
pmb = pyMBE.pymbe_library(seed=42)
308+
pmb.define_polyprotic_particle(name="PO4",
309+
sigma=0.35*pmb.units.nm,
310+
epsilon=1*pmb.units("reduced_energy"),
311+
n=3,
312+
acidity="acidic",
313+
pka_list=[2.15, 7.20, 12.35])
314+
pmb.define_molecule(name="mol", residue_list=[])
315+
pmb.define_residue(name="res", central_bead="PO4", side_chains=[])
316+
pmb.define_molecule(name="test_mol", residue_list=["res"])
317+
Z = pmb.calculate_HH(template_name="test_mol", pH_list=[0, 14])
318+
# At pH 0: fully protonated, charge ≈ 0
319+
self.assertAlmostEqual(Z[0], 0.0, places=1)
320+
# At pH 14: fully deprotonated, charge ≈ -3
321+
self.assertAlmostEqual(Z[1], -3.0, places=1)
322+
323+
def test_calculate_HH_diprotic_base(self):
324+
"""
325+
Test Henderson-Hasselbalch charge for a diprotic base at extreme pH values.
326+
"""
327+
pmb = pyMBE.pymbe_library(seed=42)
328+
pmb.define_polyprotic_particle(name="B",
329+
sigma=0.35*pmb.units.nm,
330+
epsilon=1*pmb.units("reduced_energy"),
331+
n=2,
332+
acidity="basic",
333+
pka_list=[6.0, 10.0])
334+
pmb.define_residue(name="res", central_bead="B", side_chains=[])
335+
pmb.define_molecule(name="test_mol", residue_list=["res"])
336+
Z = pmb.calculate_HH(template_name="test_mol", pH_list=[0, 14])
337+
# At pH 0: fully protonated, charge ≈ +2
338+
self.assertAlmostEqual(Z[0], 2.0, places=2)
339+
# At pH 14: fully deprotonated, charge ≈ 0
340+
self.assertAlmostEqual(Z[1], 0.0, places=2)
341+
342+
303343
if __name__ == "__main__":
304344
ut.main()

0 commit comments

Comments
 (0)